Spaces:
Running
Running
Download src/scene.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 11 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/scene.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/scene.js
-
curl -L -o scene.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/scene.js
11 kB
| // 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 }; | |
| } | |