diff --git a/core/frame-utils.ts b/core/frame-utils.ts index deb5ea6..e7f2d26 100644 --- a/core/frame-utils.ts +++ b/core/frame-utils.ts @@ -1,11 +1,25 @@ -export const HL = 8 +// Sprite canvas geometry. Every frame is padded to HL rows and SW cols so +// layers compose cleanly and the runtime can rely on fixed bounds. +export const HL = 12 +export const SW = 22 -export const pad = (lines: string[], width: number) => { +export const pad = (lines: string[], width: number = SW) => { const padded = lines.map(l => l.padEnd(width)) while (padded.length < HL) padded.push(" ".repeat(width)) return padded } +// Pad with the body block sitting in a specific row band. Useful when a layer +// only paints a few rows (e.g. an aura band at the top, or just the eyes). +export const padAt = (lines: string[], topRow: number, width: number = SW) => { + const out: string[] = [] + for (let r = 0; r < HL; r++) { + if (r >= topRow && r - topRow < lines.length) out.push(lines[r - topRow].padEnd(width)) + else out.push(" ".repeat(width)) + } + return out +} + export const mergeLayers = (layers: string[][]): string[] => { if (layers.length === 0) return [] if (layers.length === 1) return layers[0] diff --git a/core/pets/cat.ts b/core/pets/cat.ts index cdbd340..bfd81c1 100644 --- a/core/pets/cat.ts +++ b/core/pets/cat.ts @@ -1,115 +1,221 @@ -import type { PetState, PetAnimations } from "../types" -import { pad } from "../frame-utils" +import type { PetAnimations, AnimLayer, PetState } from "../types" +import { pad, padAt, SW } from "../frame-utils" +import { auraFor, groundShadow } from "./effects" -export const catFrames: Record = { - idle: [ - pad([" /\\_____/\\ "," / o o \\ ","( == ^ == )"," \\ '-' / "," (__) (__) "], 14), - pad([" /\\_____/\\ "," / - - \\ ","( == ^ == )"," \\ '-' / "," (__) (__) "], 14), - ], - happy: [ - pad([" /\\_____/\\ "," / ^ ^ \\ ","( == ω == )"," \\ '-' / "," (__) (__) "], 14), - pad([" /\\_____/\\ "," / ^ ^ \\ ","( == ω == )"," \\ '-' / "," | ♥ | "," (__) (__) "], 14), - ], - sleeping: [ - pad([" /\\_____/\\ "," / - - \\ ","( == z z )"," \\ '-' / "," (__) (__) "], 14), - pad([" /\\_____/\\ "," / - - \\ ","( == Z z )"," \\ '-' / "," (__) (__) "], 14), - ], - eating: [ - pad([" /\\_____/\\ "," / o o \\ ","( == ω == )"," \\ nom / "," (__) (__) "], 14), - pad([" /\\_____/\\ "," / ^ ^ \\ ","( == ω == )"," \\ nom / "," (__) (__) "], 14), - ], - playing: [ - pad([" /\\_____/\\ "," / ^ ^ \\"," ( == ω == ) "," \\ '-' / "," (__) (__) "], 14), - pad([" /\\_____/\\ "," / ^ ^ \\ ","( == ω == )"," \\ '-' / "," (__) (__) "], 14), - ], - excited: [ - pad([" /\\_____/\\ "," / ^ ^ \\ ","( == ω == )"," \\ '-' / "," | ♥ | "," (__) (__) "], 14), - pad([" /\\_____/\\ "," / ^ ^ \\ ","( == ω== )"," \\ '-' / "," (__) (__) "], 14), - ], - sad: [ - pad([" /\\_____/\\ "," / - - \\ ","( == T T )"," \\ '-' / "," (__) (__) "], 14), - pad([" /\\_____/\\ "," / - - \\ ","( == T T )"," \\ '-' / "," (__) (__) "," ;_; "], 14), +// Body rows 2-9 (aura sits in rows 0-1, ground shadow on row 11). +const body = (rows: string[]): string[] => padAt(rows, 2, SW) + +// Cat body — `/\___/\` ears, a fuzzy round body, and two paw pairs at the +// bottom. The eyes and nose are kept blank so the eyes layer can overlay them. +const catBody = body([ + " /\\_____/\\ ", + " ( ) ", + " | | ", + " | ω | ", + " | '---' | ", + " \\ / ", + " |_____| ", + " (__) (__) ", +]) + +const catEyes = (left: string, right: string): string[] => + padAt([` | ${left} ${right} | `], 4, SW) + +// A 2-cell tail that flicks left/right at the right edge of the body. +const tail = (frame: string[]): AnimLayer => ({ + id: "tail", + steps: frame.map(f => ({ frame: padAt([f], 8, SW), duration: 700 })), + loop: true, +}) + +const idleEyesLayer: AnimLayer = { + id: "eyes", + steps: [ + { frame: catEyes("●", "●"), durationRange: [2500, 4500] }, + { frame: catEyes("-", "-"), duration: 120 }, + { frame: catEyes("·", "·"), duration: 80 }, + { frame: catEyes("-", "-"), duration: 120 }, ], + loop: true, +} + +const idleBodyLayer: AnimLayer = { + id: "body", + steps: [{ frame: catBody, duration: 4800 }], + loop: true, +} + +const idleTailLayer: AnimLayer = tail([ + " ╱╲ ", + " ╱ ", +]) + +// catFrames is the public still-image dictionary used by static renderers +// (statusline, OpenCode seed sprite). It mirrors the body layer at frame 0. +export const catFrames: Record = { + idle: [catBody], + happy: [body([ + " /\\_____/\\ ", + " ( ^ ^ ) ", + " | ● ● | ", + " | ω | ", + " | \\___/ | ", + " \\ / ", + " |_____| ", + " (__) (__) ", + ])], + sleeping: [body([ + " /\\_____/\\ ", + " ( ─ ─ ) ", + " | - - | ", + " | ω | ", + " | ... | ", + " \\ / ", + " |_____| ", + " (__) (__) ", + ])], + eating: [body([ + " /\\_____/\\ ", + " ( o o ) ", + " | ● ● | ", + " | nom | ", + " | \\___/ | ", + " \\ / ", + " |_____| ", + " (__) (__) ", + ])], + playing: [body([ + " /\\___/\\ ", + " ( ^ ^ ) ", + " | ● ● | ", + " | ω | ", + " | \\__/ | ", + " \\ / ", + " |____| ", + " (_)( )(_) ", + ])], + excited: [body([ + " /\\_____/\\ ", + " ( ★ ★ ) ", + " | ● ● | ", + " | ω | ", + " | \\___/ | ", + " \\ / ", + " |_____| ", + " (__) (__) ", + ])], + sad: [body([ + " /\\_____/\\ ", + " ( ╥ ╥ ) ", + " | ● ● | ", + " | ω | ", + " | '---' | ", + " \\ / ", + " |_____| ", + " (__) (__) ", + ])], } +const happyBody = catFrames.happy[0] +const eatingBody = catFrames.eating[0] +const sadBody = catFrames.sad[0] +const sleepingBody = catFrames.sleeping[0] +const excitedBody = catFrames.excited[0] +const playingBody = catFrames.playing[0] + export const catAnim: PetAnimations = { states: { - idle: [ - { - id: "body", - steps: [{ frame: pad([" /\\_____/\\ ", " / \\ ", "( ^ == )", " \\ '-' / ", " (__) (__) "], 14), duration: 5000 }], - loop: true, - }, - { - id: "eyes", - steps: [ - { frame: pad([" ", " / o o \\ ", " ", " ", " "], 14), durationRange: [2000, 4000] }, - { frame: pad([" ", " / - - \\ ", " ", " ", " "], 14), duration: 150 }, - { frame: pad([" ", " / · · \\ ", " ", " ", " "], 14), duration: 80 }, - { frame: pad([" ", " / - - \\ ", " ", " ", " "], 14), duration: 150 }, - ], - loop: true, - }, - ], + idle: [idleBodyLayer, idleEyesLayer, idleTailLayer, groundShadow(), auraFor("idle")!], happy: [ { id: "base", steps: [ - { frame: pad([" /\\_____/\\ ", " / ^ ^ \\ ", "( == ω == )", " \\ '-' / ", " (__) (__) "], 14), durationRange: [1500, 3000] }, - { frame: pad([" /\\_____/\\ ", " / ^ ^ \\ ", "( == ω == )", " \\ '-' / ", " | ♥ | ", " (__) (__) "], 14), duration: 800 }, + { frame: happyBody, durationRange: [1200, 2400] }, + { frame: catFrames.excited[0], duration: 600 }, ], loop: true, }, + groundShadow(), + auraFor("happy")!, ], sleeping: [ { id: "base", steps: [ - { frame: pad([" /\\_____/\\ ", " / - - \\ ", "( == z z )", " \\ '-' / ", " (__) (__) "], 14), durationRange: [2000, 3000] }, - { frame: pad([" /\\_____/\\ ", " / - - \\ ", "( == Z z )", " \\ '-' / ", " (__) (__) "], 14), duration: 1500 }, + { frame: sleepingBody, durationRange: [2400, 3600] }, + { frame: pad([ + " ", + " ", + " /\\_____/\\ ", + " ( ─ ─ ) ", + " | - - | ", + " | ω | ", + " | ZZZ | ", + " \\ / ", + " |_____| ", + " (__) (__) ", + ], SW), duration: 1800 }, ], loop: true, }, + groundShadow(), + auraFor("sleeping")!, ], eating: [ { id: "base", steps: [ - { frame: pad([" /\\_____/\\ ", " / o o \\ ", "( == ω == )", " \\ nom / ", " (__) (__) "], 14), duration: 400 }, - { frame: pad([" /\\_____/\\ ", " / ^ ^ \\ ", "( == ω == )", " \\ nom / ", " (__) (__) "], 14), duration: 300 }, + { frame: eatingBody, duration: 360 }, + { frame: pad([ + " ", + " ", + " /\\_____/\\ ", + " ( ^ ^ ) ", + " | ● ● | ", + " | NOM | ", + " | \\___/ | ", + " \\ / ", + " |_____| ", + " (__) (__) ", + ], SW), duration: 280 }, ], loop: true, }, + groundShadow(), + auraFor("eating")!, ], playing: [ { id: "base", steps: [ - { frame: pad([" /\\_____/\\ ", " / ^ ^ \\", " ( == ω == ) ", " \\ '-' / ", " (__) (__) "], 14), duration: 500 }, - { frame: pad([" /\\_____/\\ ", " / ^ ^ \\ ", "( == ω == )", " \\ '-' / ", " (__) (__) "], 14), duration: 500 }, + { frame: playingBody, duration: 420 }, + { frame: happyBody, duration: 420 }, ], loop: true, }, + groundShadow(), + auraFor("playing")!, ], excited: [ { id: "base", steps: [ - { frame: pad([" /\\_____/\\ ", " / ^ ^ \\ ", "( == ω == )", " \\ '-' / ", " | ♥ | ", " (__) (__) "], 14), duration: 300 }, - { frame: pad([" /\\_____/\\ ", " / ^ ^ \\ ", "( == ω== )", " \\ '-' / ", " (__) (__) "], 14), duration: 300 }, + { frame: excitedBody, duration: 260 }, + { frame: happyBody, duration: 260 }, ], loop: true, }, + groundShadow(), + auraFor("excited")!, ], sad: [ { id: "base", - steps: [ - { frame: pad([" /\\_____/\\ ", " / - - \\ ", "( == T T )", " \\ '-' / ", " (__) (__) "], 14), durationRange: [4000, 6000] }, - { frame: pad([" /\\_____/\\ ", " / - - \\ ", "( == T T )", " \\ '-' / ", " (__) (__) ", " ;_; "], 14), duration: 2000 }, - ], + steps: [{ frame: sadBody, durationRange: [3000, 5000] }], loop: true, }, + groundShadow(), + auraFor("sad")!, ], }, transitions: [ @@ -117,16 +223,35 @@ export const catAnim: PetAnimations = { from: "idle", to: "happy", steps: [ - { frame: pad([" /\\_____/\\ ", " / O O \\ ", "( == ! == )", " \\ '-' / ", " (__) (__) "], 14), duration: 200 }, - { frame: pad([" /\\_____/\\ ", " / ^ ^ \\ ", "( == ω == )", " \\ '-' / ", " (__) (__) "], 14), duration: 300 }, + { frame: body([ + " /\\_____/\\ ", + " ( O O ) ", + " | ● ● | ", + " | ! | ", + " | '---' | ", + " \\ / ", + " |_____| ", + " (__) (__) ", + ]), duration: 220 }, + { frame: happyBody, duration: 280 }, ], }, { from: "happy", to: "sad", steps: [ - { frame: pad([" /\\_____/\\ ", " / - - \\ ", "( == ... == )", " \\ '-' / ", " (__) (__) "], 14), duration: 400 }, + { frame: body([ + " /\\_____/\\ ", + " ( - - ) ", + " | ● ● | ", + " | ... | ", + " | '---' | ", + " \\ / ", + " |_____| ", + " (__) (__) ", + ]), duration: 380 }, ], }, ], } + diff --git a/core/pets/effects.ts b/core/pets/effects.ts new file mode 100644 index 0000000..9f56bd2 --- /dev/null +++ b/core/pets/effects.ts @@ -0,0 +1,79 @@ +import type { PetState, AnimLayer } from "../types" +import { padAt, SW } from "../frame-utils" + +// Particle/aura layers that sit *above* the pet body. Each state gets its own +// signature effect so mood changes read instantly: sparkles for happy, drifting +// z's for sleeping, hearts for petted/excited, crumbs for eating, droplets for +// sad. Idle gets a rare twinkle so it never feels totally static. + +const auraRow = (top: string, mid: string): string[] => [top, mid] + +const auraLayer = (id: string, frames: string[][], duration: number, loop = true): AnimLayer => ({ + id, + steps: frames.map(f => ({ frame: padAt(f, 0, SW), duration })), + loop, +}) + +// Generate a band of particles that drift horizontally so the eye sees motion. +const driftFrames = (chars: string[], cols: number[]): string[][] => { + const frames: string[][] = [] + const w = SW + const positions = cols + // Two-row animation: each "frame" shifts particles slightly so the band + // feels alive without dominating the pet. + for (let phase = 0; phase < 4; phase++) { + let top = " ".repeat(w).split("") + let mid = " ".repeat(w).split("") + positions.forEach((c, i) => { + const ch = chars[(i + phase) % chars.length] + const row = (phase + i) % 2 === 0 ? top : mid + const col = (c + phase) % w + row[col] = ch + }) + frames.push(auraRow(top.join(""), mid.join(""))) + } + return frames +} + +export const auraFor = (state: PetState): AnimLayer | null => { + switch (state) { + case "happy": + return auraLayer("aura-happy", driftFrames(["✦", "·", "✧", "·"], [3, 9, 15, 19]), 350) + case "excited": + return auraLayer("aura-excited", driftFrames(["♥", "*", "♥", "*"], [4, 8, 12, 16, 20]), 250) + case "sleeping": + return auraLayer("aura-sleep", [ + padAt([" z Z ", " Z z "], 0, SW), + padAt([" Z z ", " z Z "], 0, SW), + padAt([" z Z ", " Z z Z"], 0, SW), + ], 800) + case "eating": + return auraLayer("aura-eat", driftFrames(["·", "˙", "·", "˙"], [7, 11, 13, 17]), 200) + case "sad": + return auraLayer("aura-sad", [ + padAt([" ' ' ' ' ", " ' ' ' "], 0, SW), + padAt([" ' ' ' ", " ' ' ' "], 0, SW), + ], 600) + case "playing": + return auraLayer("aura-play", driftFrames(["*", "✦", "·", "*"], [2, 8, 14, 20]), 200) + case "idle": + default: + // A single very-rare twinkle so the head-space doesn't sit dead. + return auraLayer("aura-idle", [ + padAt([" ", " "], 0, SW), + padAt([" · ", " "], 0, SW), + padAt([" ", " "], 0, SW), + ], 1800) + } +} + +// A subtle ground shadow that pulses with the body's idle breathing. Sits at +// the bottom of the canvas (HL-1). +export const groundShadow = (id = "shadow"): AnimLayer => ({ + id, + steps: [ + { frame: padAt([" ▁▁▁▁▁▁▁▁▁▁▁▁ "], 11, SW), duration: 1400 }, + { frame: padAt([" ▁▁▁▁▁▁▁▁▁▁ "], 11, SW), duration: 1400 }, + ], + loop: true, +}) diff --git a/core/pets/ghost.ts b/core/pets/ghost.ts index 448ef02..1d42faa 100644 --- a/core/pets/ghost.ts +++ b/core/pets/ghost.ts @@ -1,84 +1,186 @@ -import type { PetAnimations } from "../types" -import { pad } from "../frame-utils" +import type { PetAnimations, AnimLayer } from "../types" +import { pad, padAt, SW } from "../frame-utils" +import { auraFor, groundShadow } from "./effects" + +const body = (rows: string[]): string[] => padAt(rows, 2, SW) + +// A bigger ghost: rounded dome, soft body, wavy ectoplasm tail. +const ghostBody = body([ + " ╭─────╮ ", + " ╱ ╲ ", + " │ │ ", + " │ o │ ", + " │ │ ", + " ╲ ╱ ", + " ╲▁╱ ╲▁▁▁▁▁╲▁╱ ", + " ", +]) + +const ghostEyes = (l: string, r: string): string[] => + padAt([` │ ${l} ${r} │ `], 4, SW) + +const idleGhBodyLayer: AnimLayer = { + id: "body", + steps: [{ frame: ghostBody, duration: 4800 }], + loop: true, +} + +const idleGhEyesLayer: AnimLayer = { + id: "eyes", + steps: [ + { frame: ghostEyes("●", "●"), durationRange: [2400, 4400] }, + { frame: ghostEyes("-", "-"), duration: 130 }, + { frame: ghostEyes("·", "·"), duration: 80 }, + { frame: ghostEyes("-", "-"), duration: 130 }, + ], + loop: true, +} + +// The wavy tail wisps shift left/right — accent layer on row 8. +const idleGhTailLayer: AnimLayer = { + id: "wisp", + steps: [ + { frame: padAt([" ╲▁╱ ╲▁▁▁▁▁╲▁╱ "], 8, SW), duration: 1100 }, + { frame: padAt([" ╲▁╱╲▁▁▁▁▁╱╲▁╱ "], 8, SW), duration: 1100 }, + ], + loop: true, +} + +const happyGh = body([ + " ╭─────╮ ", + " ╱ ╲ ", + " │ ^ ^ │ ", + " │ ω │ ", + " │ boo! │ ", + " ╲ ╱ ", + " ╲▁╱ ╲▁▁▁▁▁╲▁╱ ", + " ", +]) + +const sleepingGh = body([ + " ╭─────╮ ", + " ╱ ╲ ", + " │ - - │ ", + " │ z │ ", + " │ ZZz │ ", + " ╲ ╱ ", + " ╲▁╱ ╲▁▁▁▁▁╲▁╱ ", + " ", +]) + +const eatingGh = body([ + " ╭─────╮ ", + " ╱ ╲ ", + " │ ● ● │ ", + " │ nom │ ", + " │ ~~~ │ ", + " ╲ ╱ ", + " ╲▁╱ ╲▁▁▁▁▁╲▁╱ ", + " ", +]) + +const playingGh = body([ + " ╭─────╮ ", + " ╱ ╲ ", + " │ ^ ^ │ ", + " │ ω │ ", + " │ ~~~~~ │ ", + " ╲ ╱ ", + " ╲▁╱ ╲▁▁▁▁▁╲▁╱ ", + " ", +]) + +const excitedGh = body([ + " ╭─────╮ ", + " ╱ ╲ ", + " │ ★ ★ │ ", + " │ ω │ ", + " │ BOO!! │ ", + " ╲ ╱ ", + " ╲▁╱ ╲▁▁▁▁▁╲▁╱ ", + " ", +]) + +const sadGh = body([ + " ╭─────╮ ", + " ╱ ╲ ", + " │ ╥ ╥ │ ", + " │ ;_; │ ", + " │ │ ", + " ╲ ╱ ", + " ╲▁╱ ╲▁▁▁▁▁╲▁╱ ", + " ", +]) export const ghostAnim: PetAnimations = { states: { - idle: [ - { - id: "body", - steps: [{ frame: pad([" .-. "," "," | O | "," '~~~' "], 12), duration: 5000 }], - loop: true, - }, - { - id: "eyes", - steps: [ - { frame: pad([" "," (o o) "," "," "], 12), durationRange: [2000, 4000] }, - { frame: pad([" "," (- -) "," "," "], 12), duration: 150 }, - { frame: pad([" "," (· ·) "," "," "], 12), duration: 80 }, - { frame: pad([" "," (- -) "," "," "], 12), duration: 150 }, - ], - loop: true, - }, + idle: [idleGhBodyLayer, idleGhEyesLayer, idleGhTailLayer, groundShadow(), auraFor("idle")!], + happy: [ + { id: "base", steps: [ + { frame: happyGh, durationRange: [1200, 2400] }, + { frame: excitedGh, duration: 500 }, + ], loop: true }, + groundShadow(), + auraFor("happy")!, ], sleeping: [ - { - id: "base", - steps: [ - { frame: pad([" .-. "," (- -) "," | z | "," '~~~' "], 12), durationRange: [2000, 3000] }, - { frame: pad([" .-. "," (- -) "," | Z | "," '~~~' "], 12), duration: 1500 }, - ], - loop: true, - }, - ], - happy: [ - { - id: "base", - steps: [ - { frame: pad([" .-. "," (^ ^) "," | ω | "," '~~~' "," boo! "], 12), durationRange: [1500, 3000] }, - { frame: pad([" .-. "," (^ ^) "," | ♥ | "," '~~~' "," boo! "], 12), duration: 800 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: sleepingGh, durationRange: [2400, 3600] }, + { frame: pad([ + " ", + " ", + " ╭─────╮ ", + " ╱ ╲ ", + " │ - - │ ", + " │ Z │ ", + " │ ZZZ │ ", + " ╲ ╱ ", + " ╲▁╱ ╲▁▁▁▁▁╲▁╱ ", + " ", + ], SW), duration: 1800 }, + ], loop: true }, + groundShadow(), + auraFor("sleeping")!, ], eating: [ - { - id: "base", - steps: [ - { frame: pad([" .-. "," (o o) "," | ω | "," '~~~' "," nom~ "], 12), duration: 400 }, - { frame: pad([" .-. "," (o o) "," | ω | "," '~~~' "," nom! "], 12), duration: 300 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: eatingGh, duration: 360 }, + { frame: pad([ + " ", + " ", + " ╭─────╮ ", + " ╱ ╲ ", + " │ ● ● │ ", + " │ NOM │ ", + " │ ~~~ │ ", + " ╲ ╱ ", + " ╲▁╱ ╲▁▁▁▁▁╲▁╱ ", + " ", + ], SW), duration: 280 }, + ], loop: true }, + groundShadow(), + auraFor("eating")!, ], playing: [ - { - id: "base", - steps: [ - { frame: pad([" .-. "," (^ ^) "," | ω | "," '~~~' "," ~~~ "], 12), duration: 500 }, - { frame: pad([" .-. "," (^ ^) "," | ω | "," '~~~' "," ~~~ "], 12), duration: 500 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: playingGh, duration: 420 }, + { frame: happyGh, duration: 420 }, + ], loop: true }, + groundShadow(), + auraFor("playing")!, ], excited: [ - { - id: "base", - steps: [ - { frame: pad([" .-. "," (^ ^) "," | ♥ | "," '~~~' "," BOO! "], 12), duration: 300 }, - { frame: pad([" .-. "," (^ ^) "," | ♥ | "," '~~~' "], 12), duration: 300 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: excitedGh, duration: 260 }, + { frame: happyGh, duration: 260 }, + ], loop: true }, + groundShadow(), + auraFor("excited")!, ], sad: [ - { - id: "base", - steps: [ - { frame: pad([" .-. "," (T T) "," | | "," '~~~' "], 12), durationRange: [4000, 6000] }, - { frame: pad([" .-. "," (T T) "," | ; | "," '~~~' "], 12), duration: 2000 }, - ], - loop: true, - }, + { id: "base", steps: [{ frame: sadGh, durationRange: [3000, 5000] }], loop: true }, + groundShadow(), + auraFor("sad")!, ], }, } diff --git a/core/pets/hamster.ts b/core/pets/hamster.ts index 9fd205c..7167b56 100644 --- a/core/pets/hamster.ts +++ b/core/pets/hamster.ts @@ -1,77 +1,186 @@ -import type { PetAnimations } from "../types" -import { pad } from "../frame-utils" +import type { PetAnimations, AnimLayer } from "../types" +import { pad, padAt, SW } from "../frame-utils" +import { auraFor, groundShadow } from "./effects" + +const body = (rows: string[]): string[] => padAt(rows, 2, SW) + +// Chubby hamster: two big ears, round body, cheek pouches, tiny paws. +const hamsterBody = body([ + " ╱─╲ ╱─╲ ", + " ╱ ╲_╱ ╲ ", + " │ │ ", + " │ ° ω ° │ ", + " │ ╰───╯ │ ", + " ╲ ╱ ", + " ╲_______╱ ", + " / | | \\ ", +]) + +const hamsterEyes = (l: string, r: string): string[] => + padAt([` │ ${l} ${r} │ `], 4, SW) + +const idleHamBodyLayer: AnimLayer = { + id: "body", + steps: [{ frame: hamsterBody, duration: 4600 }], + loop: true, +} + +const idleHamEyesLayer: AnimLayer = { + id: "eyes", + steps: [ + { frame: hamsterEyes("●", "●"), durationRange: [2200, 4200] }, + { frame: hamsterEyes("-", "-"), duration: 120 }, + { frame: hamsterEyes("·", "·"), duration: 80 }, + { frame: hamsterEyes("-", "-"), duration: 120 }, + ], + loop: true, +} + +// Cheek pouches puff in and out — subtle accent on the cheeks row. +const idleHamCheeksLayer: AnimLayer = { + id: "cheeks", + steps: [ + { frame: padAt([" │ ° ω ° │ "], 5, SW), duration: 900 }, + { frame: padAt([" │ ◦ ω ◦ │ "], 5, SW), duration: 600 }, + ], + loop: true, +} + +const happyHam = body([ + " ╱─╲ ╱─╲ ", + " ╱ ╲_╱ ╲ ", + " │ ^ ^ │ ", + " │ ° ω ° │ ", + " │ ╰─♥─╯ │ ", + " ╲ ╱ ", + " ╲_______╱ ", + " / ♥ \\ ", +]) + +const sleepingHam = body([ + " ╱─╲ ╱─╲ ", + " ╱ ╲_╱ ╲ ", + " │ - - │ ", + " │ ° ω ° │ ", + " │ zzz │ ", + " ╲ ╱ ", + " ╲_______╱ ", + " / \\ ", +]) + +const eatingHam = body([ + " ╱─╲ ╱─╲ ", + " ╱ ╲_╱ ╲ ", + " │ ● ● │ ", + " │ ● ω ● │ ", + " │ ╰nom╯ │ ", + " ╲ ╱ ", + " ╲_______╱ ", + " / | | \\ ", +]) + +const playingHam = body([ + " ╱─╲ ╱─╲ ", + " ╱ ╲_╱ ╲ ", + " │ ^ ^ │ ", + " │ ° ω ° │ ", + " │ run run │ ", + " ╲ ╱ ", + " ╲_______╱ ", + " / / / \\ ", +]) + +const excitedHam = body([ + " ╱─╲ ╱─╲ ", + " ╱ ╲_╱ ╲ ", + " │ ★ ★ │ ", + " │ SQUEAK │ ", + " │ ╰─!─╯ │ ", + " ╲ ╱ ", + " ╲_______╱ ", + " / ♥ ♥ \\ ", +]) + +const sadHam = body([ + " ╱─╲ ╱─╲ ", + " ╱ ╲_╱ ╲ ", + " │ ╥ ╥ │ ", + " │ ° ω ° │ ", + " │ ╰_;_╯ │ ", + " ╲ ╱ ", + " ╲_______╱ ", + " / \\ ", +]) export const hamAnim: PetAnimations = { states: { - idle: [ - { - id: "base", - steps: [ - { frame: pad([" (\\\\/) (\\\\/) "," ( ..) ( ..) "," `--'`--' "," ( ) "," ( ) "], 14), durationRange: [2000, 4000] }, - { frame: pad([" (\\\\/) (\\\\/) "," ( -.) ( -.) "," `--'`--' "," ( ) "," ( ) "], 14), duration: 150 }, - ], - loop: true, - }, + idle: [idleHamBodyLayer, idleHamEyesLayer, idleHamCheeksLayer, groundShadow(), auraFor("idle")!], + happy: [ + { id: "base", steps: [ + { frame: happyHam, durationRange: [1200, 2400] }, + { frame: excitedHam, duration: 500 }, + ], loop: true }, + groundShadow(), + auraFor("happy")!, ], sleeping: [ - { - id: "base", - steps: [ - { frame: pad([" (\\\\/) (\\\\/) "," ( -.) ( -.) "," `--'`--' "," zzz "," ( ) "], 14), durationRange: [2000, 3000] }, - { frame: pad([" (\\\\/) (\\\\/) "," ( -.) ( -.) "," `--'`--' "," ZZZ "," ( ) "], 14), duration: 1500 }, - ], - loop: true, - }, - ], - happy: [ - { - id: "base", - steps: [ - { frame: pad([" (\\\\/) (\\\\/) "," ( ^.) ( ^.) "," `--'`--' "," ( ♥ ) "," run run! "], 14), durationRange: [1500, 3000] }, - { frame: pad([" (\\\\/) (\\\\/) "," ( ^.) ( ^.) "," `--'`--' "," ( ♥ ) "], 14), duration: 800 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: sleepingHam, durationRange: [2400, 3600] }, + { frame: pad([ + " ", + " ", + " ╱─╲ ╱─╲ ", + " ╱ ╲_╱ ╲ ", + " │ - - │ ", + " │ ° ω ° │ ", + " │ ZZZ │ ", + " ╲ ╱ ", + " ╲_______╱ ", + " / \\ ", + ], SW), duration: 1800 }, + ], loop: true }, + groundShadow(), + auraFor("sleeping")!, ], eating: [ - { - id: "base", - steps: [ - { frame: pad([" (\\\\/) (\\\\/) "," ( o.) ( o.) "," `--'`--' "," nom "," ( ) "], 14), duration: 400 }, - { frame: pad([" (\\\\/) (\\\\/) "," ( ^.) ( ^.) "," `--'`--' "," nom! "," ( ) "], 14), duration: 300 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: eatingHam, duration: 320 }, + { frame: pad([ + " ", + " ", + " ╱─╲ ╱─╲ ", + " ╱ ╲_╱ ╲ ", + " │ ^ ^ │ ", + " │ ● ω ● │ ", + " │ ╰NOM╯ │ ", + " ╲ ╱ ", + " ╲_______╱ ", + " / | | \\ ", + ], SW), duration: 280 }, + ], loop: true }, + groundShadow(), + auraFor("eating")!, ], playing: [ - { - id: "base", - steps: [ - { frame: pad([" (\\\\/) (\\\\/) "," ( ^.) ( ^.) "," `--'`--' "," ( ♥ ) "," run run! "], 14), duration: 500 }, - { frame: pad([" (\\\\/) (\\\\/) "," ( ^.) ( ^.) "," `--'`--' "," wheel! "," run run! "], 14), duration: 500 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: playingHam, duration: 380 }, + { frame: happyHam, duration: 380 }, + ], loop: true }, + groundShadow(), + auraFor("playing")!, ], excited: [ - { - id: "base", - steps: [ - { frame: pad([" (\\\\/) (\\\\/) "," ( ^.) ( ^.) "," `--'`--' "," ( ♥ ) "," SQUEAK! "], 14), duration: 300 }, - { frame: pad([" (\\\\/) (\\\\/) "," ( ^.) ( ^.) "," `--'`--' "," ( ♥ ) "], 14), duration: 300 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: excitedHam, duration: 240 }, + { frame: happyHam, duration: 240 }, + ], loop: true }, + groundShadow(), + auraFor("excited")!, ], sad: [ - { - id: "base", - steps: [ - { frame: pad([" (\\\\/) (\\\\/) "," ( T.) ( T.) "," `--'`--' "," ;_; "," ( ) "], 14), durationRange: [4000, 6000] }, - { frame: pad([" (\\\\/) (\\\\/) "," ( T.) ( T.) "," `--'`--' "," ;_; "," ( ) "], 14), duration: 2000 }, - ], - loop: true, - }, + { id: "base", steps: [{ frame: sadHam, durationRange: [3200, 5000] }], loop: true }, + groundShadow(), + auraFor("sad")!, ], }, } diff --git a/core/pets/robot.ts b/core/pets/robot.ts index 1aac9bb..4dcbffe 100644 --- a/core/pets/robot.ts +++ b/core/pets/robot.ts @@ -1,84 +1,197 @@ -import type { PetAnimations } from "../types" -import { pad } from "../frame-utils" +import type { PetAnimations, AnimLayer } from "../types" +import { pad, padAt, SW } from "../frame-utils" +import { auraFor, groundShadow } from "./effects" + +const body = (rows: string[]): string[] => padAt(rows, 2, SW) + +// A proper robot: antenna with strut, boxy head with LED eyes, chest module, +// blocky legs. Each subsystem can animate independently. +const robotBody = body([ + " ┌─┐ ", + " ╱│ │╲ ", + " ┌──┴──┐ ", + " │ │ ", + " │ ┌─┐ │ ", + " │ └─┘ │ ", + " └─┬─┬─┘ ", + " ╱ ╲ ", +]) + +const robotEyes = (l: string, r: string): string[] => + padAt([` │ ${l} ${r} │ `], 5, SW) + +const idleRbBodyLayer: AnimLayer = { + id: "body", + steps: [{ frame: robotBody, duration: 5000 }], + loop: true, +} + +const idleRbEyesLayer: AnimLayer = { + id: "eyes", + steps: [ + { frame: robotEyes("◉", "◉"), durationRange: [2200, 4400] }, + { frame: robotEyes("-", "-"), duration: 120 }, + { frame: robotEyes("·", "·"), duration: 80 }, + { frame: robotEyes("-", "-"), duration: 120 }, + ], + loop: true, +} + +// Antenna LED pulses red/dot/red. +const idleRbAntennaLayer: AnimLayer = { + id: "antenna", + steps: [ + { frame: padAt([" ┌─┐ "], 2, SW), duration: 800 }, + { frame: padAt([" ┌●┐ "], 2, SW), duration: 200 }, + ], + loop: true, +} + +// Chest module pulses (a soft heartbeat). +const idleRbChestLayer: AnimLayer = { + id: "chest", + steps: [ + { frame: padAt([" │ ┌─┐ │ "], 6, SW), duration: 900 }, + { frame: padAt([" │ ├─┤ │ "], 6, SW), duration: 500 }, + ], + loop: true, +} + +const happyRb = body([ + " ┌●┐ ", + " ╱│ │╲ ", + " ┌──┴──┐ ", + " │ ^ ^ │ ", + " │ ┌♥┐ │ ", + " │ └─┘ │ ", + " └─┬─┬─┘ ", + " ╱ ╲ ", +]) + +const sleepingRb = body([ + " ┌─┐ ", + " ╱│ │╲ ", + " ┌──┴──┐ ", + " │ - - │ ", + " │ ┌─┐ │ ", + " │ └─┘ │ zzz ", + " └─┬─┬─┘ ", + " ╱ ╲ ", +]) + +const eatingRb = body([ + " ┌●┐ ", + " ╱│ │╲ ", + " ┌──┴──┐ ", + " │ ◉ ◉ │ ", + " │ ┌─┐ │ nom ", + " │ └─┘ │ ", + " └─┬─┬─┘ ", + " ╱ ╲ ", +]) + +const playingRb = body([ + " ┌●┐ ", + " ╱│ │╲ ", + " ┌──┴──┐ ", + " │ ω ω │ beep ", + " │ ┌♥┐ │ ", + " │ └─┘ │ boop ", + " └─┬─┬─┘ ", + " ╱ ╲ ", +]) + +const excitedRb = body([ + " ┌●┐ ", + " ✦╱│ │╲✦ ", + " ┌──┴──┐ ", + " │ ◉ ◉ │ BEEP ", + " │ ┌♥┐ │ ", + " │ └─┘ │ BOOP ", + " └─┬─┬─┘ ", + " ╱ ╲ ", +]) + +const sadRb = body([ + " ┌─┐ ", + " ╱│ │╲ ", + " ┌──┴──┐ ", + " │ ╥ ╥ │ ", + " │ ┌─┐ │ ;_; ", + " │ └─┘ │ ", + " └─┬─┬─┘ ", + " ╱ ╲ ", +]) export const robotAnim: PetAnimations = { states: { - idle: [ - { - id: "body", - steps: [{ frame: pad([" ___ ___ "," "," |___/ \\___|" ," \\_|_/ "], 18), duration: 5000 }], - loop: true, - }, - { - id: "eyes", - steps: [ - { frame: pad([" "," | O |---| O | "," "," "], 18), durationRange: [2000, 4000] }, - { frame: pad([" "," | - |---| - | "," "," "], 18), duration: 150 }, - { frame: pad([" "," | · |---| · | "," "," "], 18), duration: 80 }, - { frame: pad([" "," | - |---| - | "," "," "], 18), duration: 150 }, - ], - loop: true, - }, + idle: [idleRbBodyLayer, idleRbEyesLayer, idleRbAntennaLayer, idleRbChestLayer, groundShadow(), auraFor("idle")!], + happy: [ + { id: "base", steps: [ + { frame: happyRb, durationRange: [1200, 2400] }, + { frame: excitedRb, duration: 500 }, + ], loop: true }, + groundShadow(), + auraFor("happy")!, ], sleeping: [ - { - id: "base", - steps: [ - { frame: pad([" ___ ___ "," | - |---| - | "," |___/ \\___|" ," \\_|_/ zzz "], 18), durationRange: [2000, 3000] }, - { frame: pad([" ___ ___ "," | - |---| - | "," |___/ \\___|" ," \\_|_/ ZZZ "], 18), duration: 1500 }, - ], - loop: true, - }, - ], - happy: [ - { - id: "base", - steps: [ - { frame: pad([" ___ ___ "," | ^ |---| ^ | "," |___/ \\___|" ," \\_|_/ ♥ "], 18), durationRange: [1500, 3000] }, - { frame: pad([" ___ ___ "," | ^ |---| ^ | "," |___/ \\___|" ," \\_|_/ "], 18), duration: 800 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: sleepingRb, durationRange: [2400, 3600] }, + { frame: pad([ + " ", + " ", + " ┌─┐ ", + " ╱│ │╲ ", + " ┌──┴──┐ ", + " │ - - │ ", + " │ ┌─┐ │ ZZZ ", + " │ └─┘ │ ", + " └─┬─┬─┘ ", + " ╱ ╲ ", + ], SW), duration: 1800 }, + ], loop: true }, + groundShadow(), + auraFor("sleeping")!, ], eating: [ - { - id: "base", - steps: [ - { frame: pad([" ___ ___ "," | ◉ |---| ◉ | "," |___/ \\___|" ," nom nom ! "], 18), duration: 400 }, - { frame: pad([" ___ ___ "," | ◉ |---| ◉ | "," |___/ \\___|" ," nom ! "], 18), duration: 300 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: eatingRb, duration: 360 }, + { frame: pad([ + " ", + " ", + " ┌●┐ ", + " ╱│ │╲ ", + " ┌──┴──┐ ", + " │ ◉ ◉ │ ", + " │ ┌─┐ │ NOM! ", + " │ └─┘ │ ", + " └─┬─┬─┘ ", + " ╱ ╲ ", + ], SW), duration: 280 }, + ], loop: true }, + groundShadow(), + auraFor("eating")!, ], playing: [ - { - id: "base", - steps: [ - { frame: pad([" ___ ___ "," | ω |---| ω | "," |___/ \\___|" ," > boop < "], 18), duration: 500 }, - { frame: pad([" ___ ___ "," | ω |---| ω | "," |___/ \\___|" ," > beep < "], 18), duration: 500 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: playingRb, duration: 420 }, + { frame: happyRb, duration: 420 }, + ], loop: true }, + groundShadow(), + auraFor("playing")!, ], excited: [ - { - id: "base", - steps: [ - { frame: pad([" ___ ___ "," | ◉ |---| ◉ | "," |___/ \\___|" ," !! ♥ !! "], 18), duration: 300 }, - { frame: pad([" ___ ___ "," | ◉ |---| ◉ | "," |___/ \\___|" ," BEEP BOOP! "], 18), duration: 300 }, - ], - loop: true, - }, + { id: "base", steps: [ + { frame: excitedRb, duration: 240 }, + { frame: happyRb, duration: 240 }, + ], loop: true }, + groundShadow(), + auraFor("excited")!, ], sad: [ - { - id: "base", - steps: [ - { frame: pad([" ___ ___ "," | T |---| T | "," |___/ \\___|" ," ;_; "], 18), durationRange: [4000, 6000] }, - { frame: pad([" ___ ___ "," | T |---| T | "," |___/ \\___|" ," 404 :( "], 18), duration: 2000 }, - ], - loop: true, - }, + { id: "base", steps: [{ frame: sadRb, durationRange: [3200, 5000] }], loop: true }, + groundShadow(), + auraFor("sad")!, ], }, } diff --git a/scripts/preview.ts b/scripts/preview.ts new file mode 100644 index 0000000..172dcb0 --- /dev/null +++ b/scripts/preview.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env bun +import { PET_ANIMATIONS, PET_NAMES } from "../core/pets" +import { STATES } from "../core/types" +import { mergeLayers } from "../core/frame-utils" + +// Render the first-frame composite of every (pet × state) so we can eyeball +// the silhouette without spinning up the animation engine. + +for (const pet of PET_NAMES) { + const anim = PET_ANIMATIONS[pet] + for (const state of STATES) { + const layers = anim.states[state] + if (!layers) continue + const composite = mergeLayers(layers.map(l => l.steps[0].frame)) + process.stdout.write(`\n== ${pet} / ${state} ==\n`) + process.stdout.write(composite.join("\n") + "\n") + } +} diff --git a/tests/sprites.test.ts b/tests/sprites.test.ts new file mode 100644 index 0000000..9c3d504 --- /dev/null +++ b/tests/sprites.test.ts @@ -0,0 +1,50 @@ +import { test, expect } from "bun:test" +import { PET_ANIMATIONS, PET_NAMES } from "../core/pets" +import { STATES } from "../core/types" +import { HL, SW, mergeLayers } from "../core/frame-utils" + +// Guard the canvas geometry: every frame of every layer of every state must be +// exactly HL rows tall and SW cols wide. Composited frames must too. Catching +// a stray off-by-one here is much cheaper than spotting it visually later. + +for (const pet of PET_NAMES) { + const anim = PET_ANIMATIONS[pet] + + for (const state of STATES) { + const layers = anim.states[state] + test(`${pet}/${state} has at least one layer`, () => { + expect(layers).toBeDefined() + expect(layers!.length).toBeGreaterThan(0) + }) + + test(`${pet}/${state} every layer frame is HL×SW`, () => { + for (const layer of layers!) { + for (const step of layer.steps) { + expect(step.frame.length).toBe(HL) + for (const row of step.frame) { + expect(row.length).toBeGreaterThanOrEqual(SW) + } + } + } + }) + + test(`${pet}/${state} composite at frame 0 is HL rows`, () => { + const composite = mergeLayers(layers!.map(l => l.steps[0].frame)) + expect(composite.length).toBe(HL) + }) + } +} + +test("every layer step has a duration or durationRange", () => { + for (const pet of PET_NAMES) { + const anim = PET_ANIMATIONS[pet] + for (const state of STATES) { + for (const layer of anim.states[state]!) { + for (const step of layer.steps) { + const hasOne = typeof step.duration === "number" || Array.isArray(step.durationRange) + expect(hasOne).toBe(true) + } + } + } + } +})