// The loop, in order: // 1. Prince enters from the left, running, as a solid black silhouette. // 2. He stops centre-left. // 3. A Drained enters from the right, walking. Slow. Unhurried. Never runs. // 4. Player holds torch -> sword away, light opens ahead of his raised hand. // 5. Inside the light only, the Drained resolves into line art. // 6. Release -> light closes, sword returns. // 7. The Drained reaches him, holds, fades, resets. // // SPEC DEPARTURE — combat. Sections 1, 5.4 and 7 of the spec say the Drained // never accelerates, never reacts to the torch and never attacks, and list // combat as out of scope. That was overridden deliberately: once a Drained has // been revealed in the torchlight it rushes, which forces the prince to put the // torch down and fight. Standing decisions, all easy to reverse: // * No health. One touch ends the loop, using the same hold-fade-reset the // demo already had. Nothing new on screen. // * He cannot kill it. A hit knocks it back and buys time; it gets up and // keeps coming. The Drained are people, so the sword does not solve them. // * Never raise the torch and nothing changes — it ambles in as before. // // Every size, anchor and frame rate comes from the manifest by way of the // loaded clips. Nothing here hardcodes how big a character is. import { createPlayer } from './anim.js'; import { createTorch } from './torch.js'; import { DEMO } from './tuning.js'; export const W = 1920; export const H = 1080; export const GROUND_Y = 880; // --- Tunables ------------------------------------------------------------- // The demo's own numbers live in tuning.js under DEMO, separate from the // level's, so tuning the level cannot silently change the thing that is // deployed and being played. // // NOTE ON LOOP LENGTH: the spec asks for both a "fifteen-second demo" and a // Drained walk speed of 55px/sec. Those two numbers disagree — at 55px/sec the // walk across the screen alone is ~21s. Keeping the spec's speed was the call, // so the loop runs ~24s. const { DRAINED_SPEED, DRAINED_RUSH_SPEED, DRAINED_RUSH_ACCEL, KNOCKBACK_SPEED, KNOCKBACK_DECEL, STUN_TIME, PRINCE_RUN_SPEED, PRINCE_STOP_X, CONTACT_GAP, CONTACT_HOLD, FADE_TIME, } = DEMO; // -------------------------------------------------------------------------- const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); export function createScene(clips, { speedScale = 1 } = {}) { // Playtest affordance only. The demo's own numbers are unchanged at scale 1; // ?speed=3 just stops a tester waiting out the twenty-second walk-in. const baseSpeed = DRAINED_SPEED * speedScale; const rushSpeed = DRAINED_RUSH_SPEED * speedScale; const runClip = clips.prince_run; const idleClip = clips.prince_idle; const torchClip = clips.prince_torch; const guardClip = clips.prince_guard; const attackClip = clips.prince_attack; const walkClip = clips.drained_walk; const ATTACK_DURATION = attackClip.count / attackClip.fps; // The hand the torch is held in, published by the manifest in sprite // coordinates. Converted once into an offset from the prince's feet, so // moving the pose in the art moves the light with it. const hand = torchClip.handPivot ?? [torchClip.pivotX, torchClip.pivotY - 230]; const HAND_DX = hand[0] - torchClip.pivotX; const HAND_DY = hand[1] - torchClip.pivotY; // Edges derived from the art, not from numbers typed in here. const princeFrontEdge = runClip.width - runClip.pivotX; const princeBackEdge = runClip.pivotX; const drainedFrontEdge = walkClip.pivotX; // it faces left const drainedBackEdge = walkClip.width - walkClip.pivotX; const PRINCE_SPAWN_X = -princeFrontEdge - 10; const DRAINED_SPAWN_X = W + drainedFrontEdge; const DRAINED_CONTACT_X = PRINCE_STOP_X + princeFrontEdge + drainedFrontEdge + CONTACT_GAP; const prince = { x: PRINCE_SPAWN_X, prevX: PRINCE_SPAWN_X, facing: 1, hasSword: true, clip: runClip, frame: 0, run: createPlayer(runClip), idle: createPlayer(idleClip), torch: createPlayer(torchClip), guard: createPlayer(guardClip), attacking: false, attackTime: 0, attackConnected: false, }; const drained = { x: DRAINED_SPAWN_X, prevX: DRAINED_SPAWN_X, active: false, clip: walkClip, frame: 0, walk: createPlayer(walkClip), revealed: false, // latches the first time it is seen in the light speed: baseSpeed, stun: 0, knockVel: 0, }; const flame = { clip: clips.fire, player: createPlayer(clips.fire), frame: 0 }; const torch = createTorch(); const state = { prince, drained, torch, flame, ground: clips.ground, phase: 'enter', timer: 0, fade: 0, prevFade: 0, clock: 0, loopElapsed: 0, lastLoopLength: 0, }; function reset() { torch.reset(); prince.x = prince.prevX = PRINCE_SPAWN_X; prince.hasSword = true; prince.run.reset(); prince.idle.reset(); drained.x = drained.prevX = DRAINED_SPAWN_X; drained.active = false; drained.walk.reset(); drained.revealed = false; drained.speed = baseSpeed; drained.stun = 0; drained.knockVel = 0; prince.attacking = false; prince.attackTime = 0; prince.attackConnected = false; state.phase = 'enter'; state.timer = 0; state.fade = state.prevFade = 0; state.loopElapsed = 0; } function update(dt, input) { prince.prevX = prince.x; drained.prevX = drained.x; torch.snapshot(); state.prevFade = state.fade; state.clock += dt; state.loopElapsed += dt; // --- Torch (spec 5.2, 5.3) ------------------------------------------- const eased = torch.update(dt, !!input.torch, state.clock, prince.x, GROUND_Y, prince.facing, HAND_DX, HAND_DY); // The sword rule: torch up means sword away. Not negotiable. There is no // sword sprite — the sword lives in the run and idle art and is absent from // the torch clip, so swapping the clip IS putting it away. prince.hasSword = torch.raise <= 0.001; // --- Revealed? --------------------------------------------------------- // Latches the first time the Drained is caught inside the lit circle. Once // it knows it has been seen, it comes for him, and it does not settle back // down. if (!drained.revealed && drained.active && torch.covers(drained.x, GROUND_Y - walkClip.height * 0.5)) { drained.revealed = true; } const targetSpeed = drained.revealed ? rushSpeed : baseSpeed; if (drained.speed < targetSpeed) { drained.speed = Math.min(targetSpeed, drained.speed + DRAINED_RUSH_ACCEL * speedScale * dt); } // --- The sword --------------------------------------------------------- // He can only swing with the sword out, which means the torch must be down, // which means he cannot see what he is swinging at. That is the whole trade. if (input.attack) { input.attack = false; // Only while it is still coming. Once it has reached him the loop is // over, and a swing that visibly changes nothing reads as a broken input. const canSwing = prince.hasSword && !prince.attacking && state.phase === 'approach'; if (canSwing) { prince.attacking = true; prince.attackTime = 0; prince.attackConnected = false; } } if (prince.attacking) { prince.attackTime += dt; const frame = Math.min(Math.floor(prince.attackTime * attackClip.fps), attackClip.count - 1); // The manifest says which frame connects and how far the blade reaches. if (!prince.attackConnected && frame >= (attackClip.hitFrame ?? 0)) { prince.attackConnected = true; const gap = (drained.x - drainedFrontEdge) - prince.x; if (drained.active && gap <= attackClip.reach) { // It gets knocked back and it gets up. He cannot kill it — they are // people. The sword buys time, it does not solve anything. drained.knockVel = KNOCKBACK_SPEED; drained.stun = STUN_TIME; } } if (prince.attackTime >= ATTACK_DURATION) prince.attacking = false; } if (drained.knockVel > 0) { drained.x += drained.knockVel * dt; drained.knockVel = Math.max(0, drained.knockVel - KNOCKBACK_DECEL * dt); } if (drained.stun > 0) drained.stun -= dt; // --- Prince animation -------------------------------------------------- if (prince.attacking) { prince.clip = attackClip; prince.frame = Math.min(Math.floor(prince.attackTime * attackClip.fps), attackClip.count - 1); } else if (!prince.hasSword) { // Pose is driven by the light, so raising and lowering read as one move. // The clip's own fps and frame count still decide how many frames exist. prince.torch.time = (eased * (torchClip.count - 1)) / torchClip.fps; prince.clip = torchClip; prince.frame = prince.torch.index; flame.player.advance(dt); flame.frame = flame.player.index; } else if (state.phase === 'enter') { prince.run.advance(dt); prince.clip = runClip; prince.frame = prince.run.index; } else if (drained.revealed) { // Once it has been seen, he stands ready whenever he is not swinging. prince.guard.advance(dt); prince.clip = guardClip; prince.frame = prince.guard.index; } else { prince.idle.advance(dt); prince.clip = idleClip; prince.frame = prince.idle.index; } // --- Phases ---------------------------------------------------------- if (state.phase === 'enter') { // He cannot walk while looking. Movement speed drops to zero. (spec 5.2) const speed = prince.hasSword ? PRINCE_RUN_SPEED : 0; prince.x += speed * dt; if (prince.x >= PRINCE_STOP_X) { prince.x = PRINCE_STOP_X; state.phase = 'approach'; drained.active = true; } } else if (state.phase === 'approach') { if (drained.stun <= 0 && drained.knockVel <= 0) { drained.x -= drained.speed * dt; } // The walk cycle plays at the same ratio as the movement, so a faster // Drained does not moonwalk. Base rate still comes from the manifest. drained.walk.advance(dt * (drained.speed / DRAINED_SPEED)); drained.frame = drained.walk.index; if (drained.x <= DRAINED_CONTACT_X) { drained.x = DRAINED_CONTACT_X; state.phase = 'contact'; state.timer = CONTACT_HOLD; } } else if (state.phase === 'contact') { state.timer -= dt; if (state.timer <= 0) { state.phase = 'fade'; state.timer = FADE_TIME; } } else if (state.phase === 'fade') { state.timer -= dt; state.fade = clamp01(1 - state.timer / FADE_TIME); if (state.timer <= 0) { state.lastLoopLength = state.loopElapsed; reset(); } } } return { state, update, reset }; }