Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/09-ART-PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
64 changes: 64 additions & 0 deletions game/verification/transitions/trace.ts
Original file line number Diff line number Diff line change
@@ -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<string, Phase[]> = {
'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<string, unknown> = {};
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));
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions scripts/test_transition_gifs.py
Original file line number Diff line number Diff line change
@@ -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()
103 changes: 103 additions & 0 deletions scripts/transition_gifs.py
Original file line number Diff line number Diff line change
@@ -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/<name>.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()
Loading