astara / src /scene.js
system's picture
system HF Staff
1ddc8d8: A hand that holds a torch, and real fire in it
3647d73 verified
Raw History Blame Contribute Delete
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 };
}