diff --git a/docs/09-ART-PIPELINE.md b/docs/09-ART-PIPELINE.md index 7b49693..170a65d 100644 --- a/docs/09-ART-PIPELINE.md +++ b/docs/09-ART-PIPELINE.md @@ -280,6 +280,7 @@ The team's open question in A14: how do generated frames move without looking li 4. **Smears instead of blends** for fast motion: one stretched smear frame on the chair swing and the knockback, as in Spider-Verse. No motion blur or warp. 5. **One grid, one palette.** Everything is crushed to the same pixel scale and 96-colour palette, and the feet are anchored to one baseline row, so frames don't wobble. 6. **Preview before approval.** Every sheet gets a looping preview at game size on its review page. A frame that pops is redrawn on its own against its neighbours as inputs. +7. **Check the switches in motion.** `python scripts/transition_gifs.py` runs the game's own movement, jump and animation code (`game/verification/transitions/trace.ts`) through idle ↔ walk ↔ run ↔ jump ↔ land and draws each transition as a GIF at 30 fps, with ground ticks every foot that move with the world. A foot that slides against the ticks slides in the game; a clip switch should move the outline no more than the cycle's own frame-to-frame step. ## The crossing (no animation budget) diff --git a/game/verification/transitions/idle-jump-land.gif b/game/verification/transitions/idle-jump-land.gif new file mode 100644 index 0000000..bdd11b1 Binary files /dev/null and b/game/verification/transitions/idle-jump-land.gif differ diff --git a/game/verification/transitions/idle-run-idle.gif b/game/verification/transitions/idle-run-idle.gif new file mode 100644 index 0000000..b121768 Binary files /dev/null and b/game/verification/transitions/idle-run-idle.gif differ diff --git a/game/verification/transitions/idle-walk-idle.gif b/game/verification/transitions/idle-walk-idle.gif new file mode 100644 index 0000000..dc9aa58 Binary files /dev/null and b/game/verification/transitions/idle-walk-idle.gif differ diff --git a/game/verification/transitions/run-jump-land-run.gif b/game/verification/transitions/run-jump-land-run.gif new file mode 100644 index 0000000..19c60c7 Binary files /dev/null and b/game/verification/transitions/run-jump-land-run.gif differ diff --git a/game/verification/transitions/trace.ts b/game/verification/transitions/trace.ts new file mode 100644 index 0000000..4e70dab --- /dev/null +++ b/game/verification/transitions/trace.ts @@ -0,0 +1,64 @@ +/** + * The hero's locomotion transitions (#156 "Smooth transitions"), traced with the game's own code: movement.ts + * paces the body, jump.ts its arc and landing, animation.ts picks the frame – as player.ts wires them, on flat + * ground at the game's 120 Hz step. Writes one trace per transition; scripts/transition_gifs.py draws them. + * + * node --experimental-transform-types game/verification/transitions/trace.ts > tmp/transitions.json + */ +import { readFileSync } from 'node:fs'; +import { catalogue, clipLength, LocomotionAnimation, type MotionPose } from '../../src/player/animation.ts'; +import { move, movement, RUN_SPEED, WALK_SPEED } from '../../src/player/movement.ts'; +import { GRAVITY, JUMP_SPEED, jumpState, landing, LANDING_TIMES } from '../../src/player/jump.ts'; +import type { AnimationTable } from '../../src/stage/assets.ts'; + +const SPRITES = new URL('../../public/assets/sprites/', import.meta.url); +const BOARDS = ['hero-move', 'hero-run', 'hero-air', 'hero-idle']; +const tables = Object.fromEntries(BOARDS.map(board => + [board, JSON.parse(readFileSync(new URL(`${board}.anims.json`, SPRITES), 'utf8')) as AnimationTable])); +const clips = catalogue(tables); +// The landing holds, as player.ts measures them from the jump row's landing and the hard landing. +const times = clips.land && clips['hard-landing'] + ? { soft: clipLength(clips.land, 'land'), hard: clipLength(clips['hard-landing'], 'hard-landing') } : LANDING_TIMES; + +/** One input phase: hold right (axis), and/or press jump at its start, for `seconds`. */ +type Phase = { seconds: number; axis: number; jump?: boolean }; +const SCRIPTS: Record = { + 'idle-walk-idle': [{ seconds: 0.5, axis: 0 }, { seconds: 0.35, axis: 1 }, { seconds: 0.9, axis: 0 }], + // Let go for a moment and hold again: the run timer restarts, so the body slows to a walk, then runs again. + 'walk-run-walk-run': [{ seconds: 1.4, axis: 1 }, { seconds: 0.05, axis: 0 }, { seconds: 1.0, axis: 1 }, { seconds: 0.9, axis: 0 }], + 'idle-run-idle': [{ seconds: 0.4, axis: 0 }, { seconds: 1.4, axis: 1 }, { seconds: 1.1, axis: 0 }], + 'idle-jump-land': [{ seconds: 0.4, axis: 0 }, { seconds: 1.5, axis: 0, jump: true }], + 'run-jump-land-run': [{ seconds: 1.0, axis: 1 }, { seconds: 1.4, axis: 1, jump: true }], +}; + +const STEP = 1 / 120; +const out: Record = {}; +for (const [name, phases] of Object.entries(SCRIPTS)) { + const body = { x: 0, y: 0, vx: 0, vy: 0, grounded: true }; + const motion = movement(), air = jumpState(0), animation = new LocomotionAnimation(clips, () => 0.5); + const frames: { t: number; x: number; y: number; board: string; frame: string; pose: MotionPose }[] = []; + let t = 0; + for (const phase of phases) { + if (phase.jump) { body.vy = -JUMP_SPEED; body.grounded = false; air.active = true; air.origin = body.y; } + for (let n = 0; n < Math.round(phase.seconds / STEP); n++) { + const wasGrounded = body.grounded; + move(motion, phase.axis, STEP); + body.vx = motion.vx; + body.x += body.vx * STEP; + if (!body.grounded) { + body.vy += GRAVITY * STEP; body.y += body.vy * STEP; + if (body.y >= 0) { body.y = 0; body.vy = 0; body.grounded = true; } + } + landing(body, air, wasGrounded, STEP, times); + // player.ts's choice of pose, on flat ground with no ladder. + const desired: MotionPose = air.landing > 0 ? air.hard ? 'hard-landing' : 'land' + : !body.grounded ? body.vy < 0 ? 'jump' : 'fall' + : Math.abs(body.vx) > (WALK_SPEED + RUN_SPEED) / 2 ? 'run' : Math.abs(body.vx) > 5 ? 'walk' : 'idle'; + const pose = animation.update(desired, STEP, Math.abs(body.vx), { vy: body.vy, launch: JUMP_SPEED }); + frames.push({ t: +t.toFixed(4), x: +body.x.toFixed(2), y: +body.y.toFixed(2), ...pose, pose: desired }); + t += STEP; + } + } + out[name] = frames; +} +process.stdout.write(JSON.stringify(out)); diff --git a/game/verification/transitions/walk-run-walk-run.gif b/game/verification/transitions/walk-run-walk-run.gif new file mode 100644 index 0000000..c2c296d Binary files /dev/null and b/game/verification/transitions/walk-run-walk-run.gif differ diff --git a/scripts/test_transition_gifs.py b/scripts/test_transition_gifs.py new file mode 100644 index 0000000..a3b7576 --- /dev/null +++ b/scripts/test_transition_gifs.py @@ -0,0 +1,24 @@ +"""Offline checks for transition_gifs.py: the 30 fps sampling of the 120 Hz trace, and where a frame is drawn.""" +import unittest + +import transition_gifs as tg + + +class TransitionGifTests(unittest.TestCase): + def test_samples_every_fourth_step(self): + frames = [{"t": n / 120} for n in range(12)] + self.assertEqual([f["t"] for f in tg.samples(frames)], [0, 4 / 120, 8 / 120]) + + def test_place_puts_the_pivot_on_the_body(self): + # A 40x100 frame pivoted at its bottom centre, the body 10 px right of the camera and on the ground. + x, y = tg.place(110, 0, 100, (40, 100), (0.5, 1)) + self.assertEqual((x + 20, y + 100), (tg.SIZE[0] // 2 + 10, tg.GROUND)) + + def test_place_lifts_a_body_in_the_air(self): + _, ground = tg.place(0, 0, 0, (40, 100), (0.5, 1)) + _, air = tg.place(0, -30, 0, (40, 100), (0.5, 1)) + self.assertEqual(ground - air, 30) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/transition_gifs.py b/scripts/transition_gifs.py new file mode 100644 index 0000000..995b4c9 --- /dev/null +++ b/scripts/transition_gifs.py @@ -0,0 +1,103 @@ +"""The hero's locomotion transitions as GIFs (#156 "Smooth transitions"), from the game's own code. + + python scripts/transition_gifs.py # every transition -> game/verification/transitions/.gif + python scripts/transition_gifs.py idle-walk-idle # one + +game/verification/transitions/trace.ts runs movement.ts, jump.ts and animation.ts as player.ts wires them (flat +ground, 120 Hz) and prints which frame shows where; this draws it at 30 fps, 2x, with the camera on the hero and +ground ticks every foot that move with the world – a foot that slides against the ticks is a foot that slides +in the game. Each GIF frame is labelled with the clip frame and the pose the body asked for. +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from PIL import Image, ImageDraw + +ROOT = Path(__file__).resolve().parents[1] +SPRITES = ROOT / "game" / "public" / "assets" / "sprites" +TRACE = ROOT / "game" / "verification" / "transitions" / "trace.ts" +OUT = TRACE.parent +FOOT = 13 # px per foot at stage scale (docs/04) +STEP_HZ, GIF_FPS, ZOOM = 120, 30, 2 +SIZE, GROUND = (300, 250), 230 # stage px; the ground line's y (room above for the 96 px jump) +PAPER, INK, TICK = (236, 230, 216), (60, 52, 46), (150, 140, 128) + + +def trace() -> dict[str, list[dict]]: + """Run trace.ts with node (type transforms on: animation.ts has parameter properties).""" + out = subprocess.run(["node", "--experimental-transform-types", "--no-warnings", str(TRACE)], + capture_output=True, check=True, cwd=ROOT) + return json.loads(out.stdout.decode("utf-8")) + + +def samples(frames: list[dict], step_hz: int = STEP_HZ, fps: int = GIF_FPS) -> list[dict]: + """Every step_hz/fps-th traced step: what a 30 fps capture of the 120 Hz game would show.""" + every = max(1, round(step_hz / fps)) + return frames[::every] + + +def place(x: float, y: float, cam: float, size: tuple[int, int], pivot: tuple[float, float]) -> tuple[int, int]: + """Top-left of a frame whose pivot sits at body (x, y): x relative to the camera's centre, y up from the ground.""" + w, h = size + return round(SIZE[0] / 2 + (x - cam) - pivot[0] * w), round(GROUND + y - pivot[1] * h) + + +class Atlases: + def __init__(self) -> None: + self.cache: dict[str, tuple[Image.Image, dict]] = {} + + def frame(self, board: str, name: str) -> tuple[Image.Image, tuple[float, float]]: + if board not in self.cache: + data = json.loads((SPRITES / f"{board}.json").read_text(encoding="utf-8")) + self.cache[board] = (Image.open(SPRITES / f"{board}.png").convert("RGBA"), data["frames"]) + sheet, frames = self.cache[board] + if name not in frames: + sys.exit(f"{board} has no frame {name}") + r, pivot = frames[name]["frame"], frames[name].get("pivot", {"x": 0.5, "y": 1}) + return sheet.crop((r["x"], r["y"], r["x"] + r["w"], r["y"] + r["h"])), (pivot["x"], pivot["y"]) + + +def draw(name: str, frames: list[dict], atlases: Atlases) -> Path: + pictures = [] + for f in samples(frames): + cam = f["x"] + picture = Image.new("RGBA", SIZE, PAPER + (255,)) + pen = ImageDraw.Draw(picture) + pen.line([(0, GROUND), (SIZE[0], GROUND)], fill=INK) + first = int((cam - SIZE[0] / 2) // FOOT) * FOOT + for wx in range(first, int(cam + SIZE[0] / 2) + FOOT, FOOT): + sx = round(SIZE[0] / 2 + wx - cam) + pen.line([(sx, GROUND + 1), (sx, GROUND + (6 if wx % (FOOT * 5) == 0 else 3))], fill=TICK) + sprite, pivot = atlases.frame(f["board"], f["frame"]) + picture.alpha_composite(sprite, place(f["x"], f["y"], cam, sprite.size, pivot)) + pen.text((4, 3), f"{f['frame']}", fill=INK) + pen.text((4, 14), f"pose {f['pose']} t {f['t']:.2f}s", fill=TICK) + pictures.append(picture.convert("RGB").resize((SIZE[0] * ZOOM, SIZE[1] * ZOOM), Image.NEAREST)) + # One palette for the whole GIF (the hero's colours plus paper and ink), so frames don't flicker or bloat. + palette = pictures[len(pictures) // 2].quantize(colors=96, method=Image.Quantize.MEDIANCUT) + frames_p = [picture.quantize(palette=palette, dither=Image.Dither.NONE) for picture in pictures] + path = OUT / f"{name}.gif" + frames_p[0].save(path, save_all=True, append_images=frames_p[1:], duration=round(1000 / GIF_FPS), loop=0, + optimize=True) + return path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("names", nargs="*", help="transitions to draw (default: all of trace.ts)") + args = parser.parse_args() + traces, atlases = trace(), Atlases() + for name in args.names or traces: + if name not in traces: + sys.exit(f"trace.ts has no transition {name}: {', '.join(traces)}") + path = draw(name, traces[name], atlases) + print(f"{path.relative_to(ROOT).as_posix()}: {len(samples(traces[name]))} frames") + + +if __name__ == "__main__": + main()