File size: 10,967 Bytes
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
 
020d2dc
 
 
 
 
 
71eae78
 
 
 
020d2dc
 
 
71eae78
 
 
 
 
 
 
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3647d73
020d2dc
71eae78
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71eae78
020d2dc
 
 
 
 
71eae78
 
020d2dc
 
 
 
 
 
 
 
 
 
71eae78
 
 
020d2dc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
// 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 };
}