Image de fond de scène + caméra qui suit le personnage, fps d'animation cohérent
Trois retours utilisateur : - fps d'idle/interagir/touches supplémentaires alignés sur celui de la marche (8 i/s partout, était 4 pour idle — perçu comme "les autres animations sont lentes à côté de la marche"). - Nouveau menu "🏞️ Images de fond" dans "Objets de cette scène" : pose un objet kind="fond" à la taille RÉELLE de l'image choisie (pack CraftPix intégré en galerie, admin seulement — licence, voir core/sprite_gate.py), derrière tout le reste, insensible au clic. - Si ce fond dépasse la scène, elle devient le "monde" : .playScreen. playScene est désormais le viewport (overflow:hidden, taille fixe), .sceneWorld le monde à l'intérieur — la caméra centre le premier personnage trouvé, bornée pour ne jamais montrer au-delà des bords (voir personnage-controller.js::forgeUpdateSceneCamera). Le personnage peut désormais se déplacer sur tout le monde, pas seulement le petit cadre visible (clampSceneObjectPosition, actions.js). Un jeu sans fond XXL garde un comportement strictement identique à avant (monde == scène, transform vide). Bibliothèque générée une fois par scripts/generate_background_manifest.py (assets/background/, non versionné, licence CraftPix) vers static/backgrounds/ (committé), même patron que generate_animal_sprite_manifest.py. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5891c82c62
commit
9199897784
@@ -113,6 +113,25 @@ test('clampSceneObjectPosition — borne au bord gauche/haut (jamais négatif)',
|
||||
});
|
||||
});
|
||||
|
||||
// world_width/world_height (voir personnage-controller.js::
|
||||
// forgeUpdateSceneWorldSize) : demandé par l'utilisateur — un fond plus
|
||||
// grand que la scène doit laisser le personnage se déplacer sur TOUT le
|
||||
// fond, pas seulement le petit cadre visible de la scène d'origine.
|
||||
test('clampSceneObjectPosition — borne au MONDE (world_width/height) quand un fond XXL l\'a agrandi', () => {
|
||||
withScreensData({ id: 1, scene_width: 200, scene_height: 100, world_width: 2000, world_height: 1000 }, () => {
|
||||
const el = { style: { width: '40px', height: '40px' } };
|
||||
assert.equal(clampSceneObjectPosition(el, 'left', 500), 500); // pas encore au bord du MONDE
|
||||
assert.equal(clampSceneObjectPosition(el, 'left', 5000), 1960); // 2000 - 40
|
||||
});
|
||||
});
|
||||
|
||||
test('clampSceneObjectPosition — sans world_width/height (aucun fond), retombe sur scene_width/height', () => {
|
||||
withScreensData({ id: 1, scene_width: 200, scene_height: 100 }, () => {
|
||||
const el = { style: { width: '40px', height: '40px' } };
|
||||
assert.equal(clampSceneObjectPosition(el, 'left', 500), 160); // identique au tout premier test ci-dessus
|
||||
});
|
||||
});
|
||||
|
||||
test('applyObjectProperty — pos_x_relatif reste dans les limites de la scène après plusieurs pas', () => {
|
||||
withScreensData({ id: 1, scene_width: 100, scene_height: 100 }, () => {
|
||||
const el = { style: { left: '90px', top: '0px', width: '20px', height: '20px' } };
|
||||
|
||||
@@ -16,7 +16,7 @@ Object.assign(global, actionsMod);
|
||||
|
||||
const {
|
||||
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim,
|
||||
forgeRunPersonnageControllerTick,
|
||||
forgeRunPersonnageControllerTick, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera,
|
||||
} = require('../personnage-controller.js');
|
||||
|
||||
function fakeSceneEl() {
|
||||
@@ -33,7 +33,11 @@ function fakeSceneEl() {
|
||||
|
||||
function withPersonnageObject(obj, elByObjectId, fn) {
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: sel => elByObjectId[sel.match(/"(\d+)"/)[1]] };
|
||||
// forgeRunPersonnageControllerTick appelle aussi forgeUpdateSceneCamera
|
||||
// (cherche ".sceneWorld", jamais présente dans elByObjectId ici) — un
|
||||
// sélecteur qui ne matche aucun id numérique renvoie undefined plutôt
|
||||
// que de lever, comme un vrai document.querySelector sans résultat.
|
||||
global.document = { querySelector: sel => { const m = sel.match(/"(\d+)"/); return m ? elByObjectId[m[1]] : undefined; } };
|
||||
global.screensData = [{ id: 1, scene_width: 500, scene_height: 500 }];
|
||||
global.window.currentScreenId = 1;
|
||||
global.gameData = { personnage_animations: { [obj.id]: { walk: ['/w0.png'], idle: ['/i0.png'] } } };
|
||||
@@ -137,3 +141,84 @@ test('forgePersonnageActionAnim — "interagir" prioritaire, sinon la touche sup
|
||||
test('forgePersonnageActionAnim — commandes sans "extra" (personnage posé avant l\'ajout du réglage)', () => {
|
||||
assert.equal(forgePersonnageActionAnim({ interagir: 'e' }, 'j'), null);
|
||||
});
|
||||
|
||||
// Caméra qui suit le personnage derrière un fond plus grand que la scène
|
||||
// (voir screens/scenes/add_scene_object.py, kind="fond") — demandé par
|
||||
// l'utilisateur : "si l'image de fond est plus grande que la scène, la
|
||||
// caméra devra défiler en suivant le joueur".
|
||||
function fakeWorldEl() {
|
||||
return { style: {} };
|
||||
}
|
||||
|
||||
test('forgeUpdateSceneWorldSize — le monde reste la scène tant qu\'aucun fond ne dépasse (comportement inchangé)', () => {
|
||||
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'decor', x: 10, y: 10, width: 50, height: 50 }] };
|
||||
const world = forgeUpdateSceneWorldSize(screenData);
|
||||
assert.deepEqual(world, { width: 500, height: 500 });
|
||||
assert.equal(screenData.world_width, 500);
|
||||
assert.equal(screenData.world_height, 500);
|
||||
});
|
||||
|
||||
test('forgeUpdateSceneWorldSize — un fond plus grand agrandit le monde jusqu\'à ses propres bords', () => {
|
||||
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 }] };
|
||||
const world = forgeUpdateSceneWorldSize(screenData);
|
||||
assert.deepEqual(world, { width: 3840, height: 2160 });
|
||||
});
|
||||
|
||||
test('forgeUpdateSceneCamera — sans fond XXL, transform vidé (aucun effet, comportement d\'avant)', () => {
|
||||
const worldEl = fakeWorldEl();
|
||||
const previousDoc = global.document;
|
||||
global.document = { querySelector: () => worldEl };
|
||||
const screenData = { id: 1, scene_width: 500, scene_height: 500, elements: [] };
|
||||
try {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
assert.equal(worldEl.style.width, '500px');
|
||||
assert.equal(worldEl.style.height, '500px');
|
||||
assert.equal(worldEl.style.transform, '');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeUpdateSceneCamera — centre le monde sur le personnage, borné aux bords du monde', () => {
|
||||
const worldEl = fakeWorldEl();
|
||||
const playerEl = { style: { left: '1900px', top: '1000px', width: '40px', height: '60px' } };
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
};
|
||||
const screenData = {
|
||||
id: 1, scene_width: 500, scene_height: 500,
|
||||
elements: [
|
||||
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
|
||||
{ id: 42, kind: 'personnage' },
|
||||
],
|
||||
};
|
||||
try {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
assert.equal(worldEl.style.width, '3840px');
|
||||
assert.equal(worldEl.style.height, '2160px');
|
||||
// Centre du joueur : 1900+20=1920, 1000+30=1030 -> décalage caméra
|
||||
// 1920-250=1670, 1030-250=780 (aucune des deux bornes atteinte ici).
|
||||
assert.equal(worldEl.style.transform, 'translate(-1670px, -780px)');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
test('forgeUpdateSceneCamera — ne montre jamais au-delà des bords du monde (bornage)', () => {
|
||||
const worldEl = fakeWorldEl();
|
||||
const playerEl = { style: { left: '3820px', top: '0px', width: '20px', height: '20px' } }; // tout au bord droit
|
||||
const previousDoc = global.document;
|
||||
global.document = {
|
||||
querySelector: sel => (sel.indexOf('sceneWorld') !== -1 ? worldEl : playerEl),
|
||||
};
|
||||
const screenData = {
|
||||
id: 1, scene_width: 500, scene_height: 500,
|
||||
elements: [
|
||||
{ kind: 'fond', x: 0, y: 0, width: 3840, height: 2160 },
|
||||
{ id: 42, kind: 'personnage' },
|
||||
],
|
||||
};
|
||||
try {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
// max offsetX = 3840 - 500 = 3340 ; jamais plus, même si le joueur est
|
||||
// encore plus près du bord (pas de bande vide visible au-delà du monde).
|
||||
assert.equal(worldEl.style.transform, 'translate(-3340px, 0px)');
|
||||
} finally { global.document = previousDoc; }
|
||||
});
|
||||
|
||||
@@ -124,18 +124,25 @@ function applyElementProperty(targetEl, property, value) {
|
||||
targetEl.style[spec.css] = value + spec.suffix;
|
||||
}
|
||||
|
||||
// Bloque un objet de scène dans les limites de LA scène affichée (bug
|
||||
// signalé par l'utilisateur : un déplacement continu au clavier — voir
|
||||
// Bloque un objet de scène dans les limites du MONDE affiché (bug signalé
|
||||
// par l'utilisateur : un déplacement continu au clavier — voir
|
||||
// triggers.js/"touche_maintenue" — pouvait le faire sortir du cadre sans
|
||||
// rien pour l'en empêcher). scene_width/scene_height viennent de
|
||||
// screensData (voir full_game_payload.py, _screens.scene_width/height) ;
|
||||
// sans écran trouvé (jamais censé arriver pour un objet de scène), la
|
||||
// valeur n'est pas bornée plutôt que de risquer un blocage à 0.
|
||||
// rien pour l'en empêcher). world_width/world_height (voir
|
||||
// personnage-controller.js::forgeUpdateSceneWorldSize, appelée à chaque
|
||||
// tick AVANT tout déplacement) valent scene_width/scene_height tant
|
||||
// qu'aucun objet kind="fond" plus grand que la scène n'est posé — un jeu
|
||||
// sans fond XXL borne donc exactement comme avant. scene_width/height
|
||||
// viennent de screensData (voir full_game_payload.py,
|
||||
// _screens.scene_width/height) ; sans écran trouvé (jamais censé arriver
|
||||
// pour un objet de scène), la valeur n'est pas bornée plutôt que de
|
||||
// risquer un blocage à 0.
|
||||
function clampSceneObjectPosition(targetEl, css, rawValue) {
|
||||
const screenData = screensData.find(function(s){ return s.id === window.currentScreenId; });
|
||||
if (!screenData || screenData.scene_width == null) return rawValue;
|
||||
const size = parseFloat(targetEl.style[css === 'left' ? 'width' : 'height']) || 0;
|
||||
const max = (css === 'left' ? screenData.scene_width : screenData.scene_height) - size;
|
||||
const boundWidth = screenData.world_width != null ? screenData.world_width : screenData.scene_width;
|
||||
const boundHeight = screenData.world_height != null ? screenData.world_height : screenData.scene_height;
|
||||
const max = (css === 'left' ? boundWidth : boundHeight) - size;
|
||||
return Math.max(0, Math.min(max, rawValue));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,12 @@
|
||||
var activePersonnageControllerInterval = null;
|
||||
var activePersonnageInteractListener = null;
|
||||
|
||||
// Un seul rythme pour TOUTES les poses (marche, idle, interagir, touches
|
||||
// supplémentaires) — les désynchroniser (ex. idle plus lent que walk)
|
||||
// n'a aucune justification et se voit immédiatement au jeu (bug signalé
|
||||
// par l'utilisateur).
|
||||
var FORGE_PERSONNAGE_FPS = 8;
|
||||
|
||||
function forgePersonnageAnimExists(elementId, animName) {
|
||||
var byAnim = (gameData.personnage_animations || {})[elementId];
|
||||
return !!(byAnim && byAnim[animName] && byAnim[animName].length);
|
||||
@@ -33,6 +39,53 @@ function forgePersonnageIdleAnim(elementId) {
|
||||
return forgePersonnageAnimExists(elementId, 'idle') ? 'idle' : null;
|
||||
}
|
||||
|
||||
// Taille du "monde" (voir static/style.css .sceneWorld) : la scène elle-
|
||||
// même par défaut, agrandie si un objet kind="fond" (voir
|
||||
// add_scene_object.py) dépasse ses bords — un jeu sans fond XXL garde
|
||||
// donc EXACTEMENT son comportement d'avant (monde == scène). Mémorisée
|
||||
// directement sur screenData (déjà l'objet partagé avec screensData,
|
||||
// voir static/js/play/actions.js) pour que clampSceneObjectPosition
|
||||
// borne un déplacement au monde entier, pas seulement au petit cadre
|
||||
// visible de la scène.
|
||||
function forgeUpdateSceneWorldSize(screenData) {
|
||||
var width = screenData.scene_width, height = screenData.scene_height;
|
||||
(screenData.elements || []).forEach(function (o) {
|
||||
if (o.kind !== 'fond') return;
|
||||
width = Math.max(width, (o.x || 0) + (o.width || 0));
|
||||
height = Math.max(height, (o.y || 0) + (o.height || 0));
|
||||
});
|
||||
screenData.world_width = width;
|
||||
screenData.world_height = height;
|
||||
return { width: width, height: height };
|
||||
}
|
||||
|
||||
// Caméra qui suit le personnage (le premier objet kind="personnage"
|
||||
// trouvé — aucune notion de "joueur" distincte n'existe ailleurs dans le
|
||||
// moteur 2D, voir personnage-controller.js dans son ensemble) : centre le
|
||||
// monde sur lui, borné pour ne jamais montrer au-delà de ses bords (même
|
||||
// principe que clampSceneObjectPosition, actions.js, mais pour la caméra
|
||||
// plutôt qu'un objet). Sans effet (transform vidé) tant que le monde ne
|
||||
// dépasse pas la scène — c'est ce qui garde le comportement identique à
|
||||
// avant pour tout jeu sans fond plus grand que sa scène.
|
||||
function forgeUpdateSceneCamera(screenData) {
|
||||
var worldEl = document.querySelector('.playScreen[data-screen-id="' + screenData.id + '"] .sceneWorld');
|
||||
if (!worldEl) return;
|
||||
var world = forgeUpdateSceneWorldSize(screenData);
|
||||
worldEl.style.width = world.width + 'px';
|
||||
worldEl.style.height = world.height + 'px';
|
||||
if (world.width <= screenData.scene_width && world.height <= screenData.scene_height) {
|
||||
worldEl.style.transform = '';
|
||||
return;
|
||||
}
|
||||
var player = (screenData.elements || []).find(function (o) { return o.kind === 'personnage'; });
|
||||
var playerEl = player && document.querySelector('[data-object-id="' + player.id + '"]');
|
||||
var px = playerEl ? (parseFloat(playerEl.style.left) || 0) + (parseFloat(playerEl.style.width) || 0) / 2 : world.width / 2;
|
||||
var py = playerEl ? (parseFloat(playerEl.style.top) || 0) + (parseFloat(playerEl.style.height) || 0) / 2 : world.height / 2;
|
||||
var offsetX = Math.min(Math.max(px - screenData.scene_width / 2, 0), Math.max(0, world.width - screenData.scene_width));
|
||||
var offsetY = Math.min(Math.max(py - screenData.scene_height / 2, 0), Math.max(0, world.height - screenData.scene_height));
|
||||
worldEl.style.transform = 'translate(' + (-offsetX) + 'px, ' + (-offsetY) + 'px)';
|
||||
}
|
||||
|
||||
function forgeStopPersonnageControllers() {
|
||||
if (activePersonnageControllerInterval) {
|
||||
clearInterval(activePersonnageControllerInterval);
|
||||
@@ -47,6 +100,7 @@ function forgeStopPersonnageControllers() {
|
||||
// 50ms (20 fois/seconde) — même cadence que runScreenHeldKeyTriggers
|
||||
// (triggers.js), pour un déplacement perçu comme continu.
|
||||
function forgeRunPersonnageControllerTick(screenData) {
|
||||
forgeUpdateSceneCamera(screenData);
|
||||
(screenData.elements || []).forEach(function (obj) {
|
||||
if (obj.kind !== 'personnage' || !obj.personnage_commandes) return;
|
||||
var cmd = obj.personnage_commandes;
|
||||
@@ -63,9 +117,13 @@ function forgeRunPersonnageControllerTick(screenData) {
|
||||
if (heldKeys.has(cmd.bas)) dy += cmd.vitesse;
|
||||
}
|
||||
|
||||
// Même fps pour idle et walk (bug signalé par l'utilisateur : idle
|
||||
// tournait à 4 i/s contre 8 pour walk, perçue comme "les autres
|
||||
// animations sont lentes à côté de la marche" — aucune raison réelle
|
||||
// de les désynchroniser).
|
||||
if (dx === 0 && dy === 0) {
|
||||
var idleAnim = forgePersonnageIdleAnim(obj.id);
|
||||
if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: 4, loop: true }));
|
||||
if (idleAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: idleAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
|
||||
return;
|
||||
}
|
||||
if (dx !== 0) {
|
||||
@@ -74,7 +132,7 @@ function forgeRunPersonnageControllerTick(screenData) {
|
||||
}
|
||||
if (dy !== 0) applyObjectProperty(targetEl, 'pos_y_relatif', dy);
|
||||
var moveAnim = forgePersonnageMovementAnim(obj.id);
|
||||
if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: 8, loop: true }));
|
||||
if (moveAnim) runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: moveAnim, fps: FORGE_PERSONNAGE_FPS, loop: true }));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -100,7 +158,7 @@ function forgeStartPersonnageInteractListener(screenData) {
|
||||
if (!animName || !forgePersonnageAnimExists(obj.id, animName)) return;
|
||||
var targetEl = document.querySelector('[data-object-id="' + obj.id + '"]');
|
||||
if (!targetEl) return;
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: animName, fps: 8, loop: false }));
|
||||
runSpriteAnimation(targetEl, resolveSpriteFrames(obj.id, { animation: animName, fps: FORGE_PERSONNAGE_FPS, loop: false }));
|
||||
});
|
||||
};
|
||||
window.addEventListener('keydown', activePersonnageInteractListener);
|
||||
@@ -112,7 +170,15 @@ function forgeStartPersonnageInteractListener(screenData) {
|
||||
function forgeStartPersonnageControllers(screenData) {
|
||||
forgeStopPersonnageControllers();
|
||||
if (!screenData || screenData.kind !== 'jeu_2d') return;
|
||||
var hasPersonnage = (screenData.elements || []).some(function (o) { return o.kind === 'personnage'; });
|
||||
var elements = screenData.elements || [];
|
||||
var hasPersonnage = elements.some(function (o) { return o.kind === 'personnage'; });
|
||||
var hasFond = elements.some(function (o) { return o.kind === 'fond'; });
|
||||
// Appliqué tout de suite (avant le premier tick de l'intervalle, 50ms
|
||||
// plus tard) pour éviter un flash d'une frame à la taille/caméra par
|
||||
// défaut. Un fond seul (sans personnage) n'a besoin que de CET appel
|
||||
// ponctuel — rien à suivre en continu ; un personnage, lui, a besoin du
|
||||
// tick régulier pour le déplacement continu au clavier.
|
||||
if (hasFond) forgeUpdateSceneCamera(screenData);
|
||||
if (!hasPersonnage) return;
|
||||
activePersonnageControllerInterval = setInterval(function () { forgeRunPersonnageControllerTick(screenData); }, 50);
|
||||
forgeStartPersonnageInteractListener(screenData);
|
||||
@@ -125,6 +191,6 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
forgePersonnageAnimExists, forgePersonnageMovementAnim, forgePersonnageIdleAnim, forgePersonnageActionAnim,
|
||||
forgeRunPersonnageControllerTick, forgeStartPersonnageControllers, forgeStopPersonnageControllers,
|
||||
forgeStartPersonnageInteractListener,
|
||||
forgeStartPersonnageInteractListener, forgeUpdateSceneWorldSize, forgeUpdateSceneCamera,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
// animation-timeline.js, personnage-panel.js, personnage-preview.js) est
|
||||
// réutilisée TELLE QUELLE, chargée telle quelle par scene_edit.html.
|
||||
|
||||
function addSceneObject(kind, forgeCharacter) {
|
||||
function addSceneObject(kind, forgeCharacter, backgroundSlug) {
|
||||
var form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = "/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/scene-objects/add";
|
||||
var html = '<input type="hidden" name="kind" value="' + kind + '">';
|
||||
if (forgeCharacter) html += '<input type="hidden" name="forge_character" value="' + forgeCharacter + '">';
|
||||
if (backgroundSlug) html += '<input type="hidden" name="background_slug" value="' + backgroundSlug + '">';
|
||||
form.innerHTML = html;
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
|
||||
Reference in New Issue
Block a user