diff --git a/.gitignore b/.gitignore index 7dfbf03..9b2cf41 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,9 @@ reference-conversation.md # Visual-companion brainstorming session artifacts (per-session HTML mockups) .superpowers/ + +# demo render artifacts + local review screenshots (generated) +examples/*/build/ +.playwright-mcp/ +*-filmstrip-*.png +E-*.png diff --git a/docs/superpowers/demos-house-style.md b/docs/superpowers/demos-house-style.md index 882765c..f5671c0 100644 --- a/docs/superpowers/demos-house-style.md +++ b/docs/superpowers/demos-house-style.md @@ -1,58 +1,96 @@ # TextMeasure.jl Demos — House Style Single source of truth for the demo gallery (#E #F #G #H #K). All five demos MUST use -these exact values — no ranges, no per-demo drift. Authored by the design review pass -(2026-05-28), grounded in type-contrast / palette-discipline / spatial-composition / -depth / restraint principles. Coherence ≠ uniformity: a map, a magazine cover, and a TUI -should differ — but they share this spine so the gallery reads as authored, not assembled. +these exact values — no ranges, no per-demo drift. Redesigned 2026-06-13 under +[Impeccable](https://github.com/pbakaus/impeccable) + frontend-design guidance, anchored +by [`DESIGN.md`](../../examples/DESIGN.md) (the craft rubric) and [`PRODUCT.md`](../../examples/PRODUCT.md) +(what each piece must prove). Supersedes the 2026-05-28 spine (Liberation Serif / DejaVu +Sans / pure-white / three co-equal accents), which read disciplined-but-generic. + +**Concept: "editorial instrument."** A compositor's eye (high-contrast editorial serif) +metered by a measuring tool (fixed-pitch mono). Coherence ≠ uniformity: a map, a magazine, +and a TUI differ — but they share this spine so the gallery reads as *authored by one +hand*, exact to the pixel, memorable in one detail. + +## 1. Type — two faces, contrast axis + +- **Serif** (display / title / deck / body / editorial): **Fraunces** (OFL). Use its + **display** optical size for mastheads (loud, high-contrast, light WONK) and its **text** + optical size for body / #K columns (calmed, justifies cleanly). +- **Mono** (caption / label / stat / axis / footer / **#E TUI**): **IBM Plex Mono** (OFL). + The fixed pitch *is* the concept — every advance exact. + +**Sourcing (determinism).** Static TTFs are pinned in `examples/fonts/` (committed, with +each family's `OFL.txt`) — no system fonts, no variable-axis instances, so glyph advances and +goldens stay reproducible. **Load by file path, not family name:** these static instances +split weights/optical sizes into separate name-table families (`Fraunces 9pt`, +`Fraunces 9pt Black`, `IBM Plex Mono Medm`, …), so there is no single "Fraunces" family to +select — path-loading is the only deterministic selector. Pinned files: + +- Body / small → `Fraunces/Fraunces9pt-{Regular,Italic,SemiBold,Black}.ttf` (text opsz) +- Subhead / title / deck → `Fraunces/Fraunces72pt-{Regular,Black}.ttf` +- Masthead → `Fraunces/Fraunces144pt-{Light,Regular,Black}.ttf` (display opsz) +- Mono (caption/label/stat/footer/TUI) → `IBMPlexMono/IBMPlexMono-{Regular,Italic,Text,Medium,SemiBold,Bold}.ttf` + +Fixed size ramp (pt) — re-founded on **√2 (≈1.414)**, the ISO-216 paper ratio (the type +scale shares the proportion of the page it prints on). Pick the tier by role, never an +in-between value. Locked contrast anchors: **title = 2× body, display = 4× body.** + +| Tier | pt | Face | Role | +|---------|----|------|------| +| caption | 9 | IBM Plex Mono | footers, source lines, axis/figure notes (legibility floor) | +| body | 11 | Fraunces text | paragraph prose, abstracts, column text | +| subhead | 16 | Fraunces | panel/section headers, stat labels, column titles | +| title | 22 | Fraunces | article/panel title (e.g. DOI panel headlines) **(2× body)** | +| deck | 31 | Fraunces | section decks / secondary display | +| display | 44 | Fraunces display | mastheads only: CALIFORNIA, "The Newer Yorker", main figure title **(4× body)** | -## 1. Type +Display-to-body contrast = **4×**; title-to-body = **2×**. Locked — do not soften. -- **Serif** (body / editorial / titles): `Liberation Serif` -- **Sans** (labels / captions / UI / stats / TUI): `DejaVu Sans` +Body sets **ragged-right** by default (no full-justify) **except #K**, whose justified +columns *are* the demo. -Both are already pinned in the demos — no new font installs. +## 2. Palette — two layers, OKLCH-tuned, locked -Fixed size ramp (pt) — pick the tier by role, never an in-between value: +Identity comes from paper + ink + one signature; the old blue/green/red are demoted to +**data encoding only** (a bar, a winner, a correctness flag *inside* a demo), never +identity decoration. -| Tier | pt | Role | -|---------|----|------| -| caption | 9 | footers, source lines, axis/figure captions | -| body | 11 | paragraph prose, abstracts, column text | -| subhead | 14 | panel/section headers, stat labels, column titles | -| title | 22 | article/panel title (e.g. DOI panel headlines) | -| display | 44 | mastheads only: VERMONT, "The Newer Yorker", main figure title | +### Identity layer -Display-to-body contrast = **4×**; title-to-body = **2×**. Locked — do not soften. - -Body sets **ragged-right** by default (no full-justify) **except #K**, whose justified -columns *are* the demo. +| Name | Hex | RGBA | Role | +|------|-----|------|------| +| PAPER (background) | `#FBFAF7` | `RGBA(0.984, 0.980, 0.969, 1)` | every surface — off-white stock, faint chroma toward brass. **Never `#FFFFFF`.** | +| INK (text) | `#1E1C1A` | `RGBA(0.118, 0.110, 0.102, 1)` | body / titles — warm near-black | +| BRASS (signature) | `#B5793C` | `RGBA(0.710, 0.475, 0.235, 1)` | masthead **rules & datelines** (never the wordmark itself — brass carries structure/accents, not large identity text), structural rules, footer middot, registration/measurement marks. The one color threading all five pieces. | -## 2. Palette — 3 accents + 1 gray, locked +### Data layer (encoding-only — NOT identity) -| Name | Hex | RGBA | Used by | -|-------|-----|------|---------| -| BLUE (neutral/data) | `#2B6CB0` | `RGBA(0.169, 0.424, 0.690, 1)` | #F citation bars + tag chips; #H skyline inset base tone | -| GREEN (good/winner) | `#1B7A3D` | `RGBA(0.106, 0.478, 0.239, 1)` | #G silhouette fill; #K K–P column label; #F green-OA label | -| RED (problem/attention) | `#C0392B` | `RGBA(0.753, 0.224, 0.169, 1)` | #H "A Correctness Exhibit" subtitle; #K river overlays | -| GRAY (captions/footers/rules) | `#6B7280` | `RGBA(0.420, 0.447, 0.502, 1)` | all footers, captions, hairline rules (alpha 0.15–1.0 per below) | +| Name | Hex | RGBA | Used by | +|------|-----|------|---------| +| BLUE (data/neutral) | `#2E6FB5` | `RGBA(0.180, 0.435, 0.710, 1)` | #F citation bars + tag chips; #H skyline inset base tone | +| GREEN (good/winner) | `#2E7D4F` | `RGBA(0.180, 0.490, 0.310, 1)` | #G silhouette fill; #K K–P column label; #F green-OA label | +| RED (problem/attention) | `#C0432F` | `RGBA(0.753, 0.263, 0.184, 1)` | #H "A Correctness Exhibit" subtitle; #K river overlays | +| GRAY (structure) | `#6B7280` | `RGBA(0.420, 0.447, 0.502, 1)` | captions/footers body, hairline rules (alpha 0.15–1.0 per §5) | -Background = pure white `#FFFFFF`. Body text = near-black `#1A1A1A` `RGBA(0.10,0.10,0.10,1)`. -These four are the ONLY accent colors. No other blues/greens/reds anywhere. +PAPER, INK, BRASS + the four data colors are the ONLY colors. No other hues anywhere. A +data color used for identity (or off its assigned meaning) is a defect. ## 3. Footer (all print pieces #F #G #H #K) - **Text**: `TextMeasure.jl · ` (middot U+00B7, single spaces) — e.g. - `TextMeasure.jl · DOI Infographic` / `· Vermont` / `· The Newer Yorker` / `· Knuth–Plass` -- **Font/size**: DejaVu Sans, 9 pt (caption tier) -- **Color**: GRAY `#6B7280` + `TextMeasure.jl · DOI Infographic` / `· California` / `· The Newer Yorker` / `· Knuth–Plass` +- **Font/size**: IBM Plex Mono, 9 pt (caption tier) +- **Color**: text in GRAY `#6B7280`; the **middot in BRASS** `#B5793C` (the signature's + smallest appearance) - **Position**: bottom-left, baseline on the inner margin line - **Outer margin**: **36 px on all four sides** of every print piece (content inside this box) -- **TUI #E**: no px footer; reserve the bottom border row for the same string. +- **TUI #E**: no px footer; reserve the bottom border row for the same string (mono, ink on + paper-equivalent cell, brass middot). ## 4. Caption / figure-note style -- Font: DejaVu Sans · Size: 9 pt (caption tier) · Color: GRAY `#6B7280` +- Font: IBM Plex Mono · Size: 9 pt (caption tier) · Color: GRAY `#6B7280` - Align: **LEFT**, anchored to the left edge of the content block it describes (never page-centered) - Leading: 1.3× - Applies to: #K figure note, #G footer, #F per-panel DOI/source line, #H byline. @@ -60,5 +98,9 @@ These four are the ONLY accent colors. No other blues/greens/reds anywhere. ## 5. Rules / hairlines (shared) - Hairline (separators/gutters): 0.5 px, GRAY `#6B7280` @ alpha **0.15** -- Structural rules (masthead underline, pull-quote rules): 1.0 px, GRAY @ alpha **1.0** +- Structural rules (masthead underline, pull-quote rules): 1.0 px, **BRASS `#B5793C` @ alpha + 1.0** (the signature does the structural work; reserve GRAY @ 1.0 for non-signature rules) - Chart baselines (#F bars): 1 px GRAY @ alpha **0.25** +- Registration / measurement marks (optional per piece): BRASS, 0.5 px — crop-mark / + surveyor's-tick motif reinforcing the "instrument" concept. Use sparingly; restraint over + decoration. diff --git a/docs/superpowers/plans/2026-05-28-E-asteroid-tui.md b/docs/superpowers/plans/2026-05-28-E-asteroid-tui.md new file mode 100644 index 0000000..5cb5d9f --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-E-asteroid-tui.md @@ -0,0 +1,1073 @@ +# Asteroid TUI (#E) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A Tachikoma terminal asteroid blaster whose prose is shape-packed into procedural silhouettes, fractured on word boundaries on impact, and reflowed live — demonstrating TextMeasure's *measure-once, layout-many* across a renderer-agnostic game core with a committed golden-frame regression test. + +**Architecture:** The game writes to a **renderer-agnostic `CellBuffer`** (`Matrix{Char}` + 256-color + bold). A pure `tick!(state, input)` advances physics; `draw!(buf, state)` paints the buffer using `prepare`/`shape_pack`/`subprep` (TextMeasure + TextMeasureLayouts) over `rasterize`/`voronoi_shatter`/`asteroid_polygon` silhouettes (Silhouettes), plus `FigletBackend` display type. The CI golden test drives 60 scripted ticks against a seeded RNG and checksums the `CellBuffer` — **it never instantiates a renderer**. A separate Tachikoma renderer drains the `CellBuffer` to the screen for interactive play (tier-2/3, not unit-tested). + +**Tech Stack:** Julia 1.12, TextMeasure (`prepare`/`layout`/`subprep`/`FigletBackend`), TextMeasureLayouts (`shape_pack`/`raster_chord_fn`), Silhouettes (`asteroid_polygon`/`voronoi_shatter`/`rasterize`), GeometryBasics (`Point2`), Tachikoma 2.1.0 (renderer only), SHA + Random + Printf (stdlib). + +--- + +## Probed API facts (verified against the live installed versions — DO NOT re-derive) + +These were confirmed by running probe scripts against the instantiated env. Honor them exactly; several contradict naive assumptions. + +- **`measure` / `font_metrics` are NOT exported** from TextMeasure. Call `TextMeasure.measure(b, s)` and `TextMeasure.font_metrics(b)` (fully qualified). Exported names: `AbstractMeasurementBackend, FigletBackend, FontMetrics, FreeTypeBackend, Layout, Line, MakieBackend, MonospaceBackend, Prepared, TextBounds, layout, line_top, measure_bounds, prepare, subprep`. +- **`line_top(lay, ::Int)` does NOT exist** — only `line_top(::Layout, ::Line)`. Use placement baselines from `shape_pack` directly; if you need a line top, pass `lay.lines[i]`. +- `Segment` fields: `(:str, :width, :kind)` — `.str` is the text, `.kind ∈ (:word,:space,:newline)`. `Prepared`: `(:segments, :metrics)`. `FontMetrics`: `(:ascent, :descent, :line_advance)` (positional ctor `FontMetrics(ascent, descent, line_advance)`). `Layout`: `(:lines, :size, :metrics)`. `Line`: `(:str, :width, :x, :baseline)`. +- `subprep(prep, r::AbstractUnitRange) -> Prepared`; `subprep(prep, 1:length(prep.segments)).segments == prep.segments` (verified). Slicing never drops/dupes segments. +- **FigletBackend (Standard font), pinned widths:** `measure("PEW")==29.0`, `measure("PEW ")==31.0`, `measure("ARWING")==53.0`, `measure("hello")==31.0`; `font_metrics == FontMetrics(5.0, 1.0, 6.0)`. `FigletBackend(font="Small")`: `measure("PEW")==23.0`, metrics `(4.0,1.0,5.0)`. Default `FigletBackend()` uses Standard and works on case-sensitive FS (the ext lowercases internally). +- **MonospaceBackend** fields `(:fontsize, :advance_ratio, :lineheight_ratio)`; defaults give 0.6·fontsize per char and 1.2·fontsize line advance. We do **not** use it for prose packing (non-integer cells); we ship a `CellBackend` instead. +- **`shape_pack(prep, chord_fn; line_advance, min_chord_width=24, overflow_strategy=:widest_row, …)`** — `line_advance` is an **explicit kwarg decoupled from metrics**. Returns `PackedLayout(placements::Vector{Placement}, overflowed::Vector{Int}, metrics)`; `Placement(segment_index, x, y)` where `segment_index` is the **absolute** index into `prep.segments`, `x` = left edge, `y` = baseline (block-top frame). `raster_chord_fn(raster::BitMatrix, cell_size::Real)`; cell `(row,col)` covers `x∈[(col-1)cs, col·cs]`, `y∈[(row-1)cs, row·cs]`, `row=1` is top. +- **Silhouettes:** `asteroid_polygon(rng; n=12, lumpiness=0.4)` → open CCW `Vector{GeometryBasics.Point{2,Float64}}` (alias `Point2{Float64}`). `rasterize(polygon, cell_size>0)` → `BitMatrix`, `row 1` = top (y-down). `voronoi_shatter(polygon, impact::Point2{Float64}; n_shards=4)` → `Vector{Vector{Point2{Float64}}}`, `length ≤ n_shards`, partitions the parent; uses an input-derived local RNG (no global RNG side effects). All deterministic given inputs. +- **Tachikoma 2.1.0 (renderer only, NOT unit-tested):** `Rect(x,y,width,height)`; `Buffer(rect::Rect)`; `set_char!(buf,x,y,ch[,style])`; `set_string!(buf,x,y,str[,style;max_x])`; `set_style!(buf,rect,style)`; `Style(; fg,bg,bold,dim,italic,underline,strikethrough,hyperlink)`; `Color256(n::Int)`; `ColorRGB(r,g,b)`; `buffer_to_text(buf, rect)`; `TestBackend(w,h)` exposes `char_at(tb,x,y)`, `style_at`, `row_text(tb,y)` for headless inspection; `Terminal(; io, size, remote_tty_path)`. High-level `@tachikoma_app`, `Model`, `app` exist but resolve only as `TK.@tachikoma_app` etc. (they are not bound in `Main`). Tachikoma's `x,y` are column,row 1-based. +- **Determinism:** `Xoshiro(seed)` (from `Random`) for all RNG. `voronoi_shatter` and `asteroid_polygon` take an explicit RNG / are input-seeded, so the whole core is reproducible. **Checksums use `SHA.sha256` over a canonical byte encoding** (stdlib, stable across Julia versions) — NOT `Base.hash` (version-unstable, unsafe for a committed golden). + +--- + +## File structure + +All paths under `examples/asteroid_tui/`. Already created by setup: `Project.toml` (deps: TextMeasure, TextMeasureLayouts, Silhouettes, FIGlet, GeometryBasics, Tachikoma, Random, Printf; `julia="1.12"`; `[extras] Test` + `[targets] test=["Test"]`), `src/AsteroidTUI.jl` (skeleton), `test/runtests.jl` (skeleton), `README.md`. **Add `SHA` to deps** in Task 1. + +| File | Responsibility | +|------|----------------| +| `src/AsteroidTUI.jl` | Module: `include`s, `using`s, exports. | +| `src/cellbuffer.jl` | `CellBuffer` (renderer-agnostic), `clear!`/`put_char!`/`put_string!`/`checksum`/`to_text`. | +| `src/cellbackend.jl` | `CellBackend <: AbstractMeasurementBackend` — 1 cell/grapheme, metrics `(1,0,1)`. | +| `src/prose.jl` | Procedural prose pool (≥50 distinct templates), `asteroid_prose(rng)`. | +| `src/entities.jl` | `Ship`, `Asteroid`, `Shard`, `Beam` structs + physics fields. | +| `src/game.jl` | `GameState`, `new_game`, `tick!`, spawn/respawn, charge, beam, collision, fracture. | +| `src/pack.jl` | `pack_prose_into(polygon, prep; …)` helper: rasterize → raster_chord_fn → shape_pack → placements in cell coords. | +| `src/draw.jl` | `draw!(buf, state)` — paints asteroids/ship/beam/tags/charge/debug into the `CellBuffer`. | +| `src/input.jl` | `Input` struct + `ScriptedInput` (deterministic sequence for tests). | +| `src/render_tachikoma.jl` | Drains `CellBuffer`→Tachikoma `Buffer`; interactive loop. **Not unit-tested.** | +| `run.jl` | Interactive entry point. | +| `test/runtests.jl` | Aggregator. | +| `test/test_cellbuffer.jl` | Buffer ops + checksum determinism/stability. | +| `test/test_cellbackend.jl` | `measure`/`font_metrics` conformance. | +| `test/test_prose.jl` | ≥50 distinct templates, deterministic by seed. | +| `test/test_pack.jl` | `pack_prose_into` places words inside the silhouette; coords in-bounds. | +| `test/test_game.jl` | `tick!` physics, charge stages, respawn/invuln counters. | +| `test/test_fracture.jl` | Glyph-preservation: shard prose concatenation == original word order. | +| `test/test_golden.jl` | 60-tick scripted run → checksum vs committed golden + glyph order. | +| `test/golden/frame60.sha256` | Committed golden checksum (text). | +| `test/golden/frame60.txt` | Committed sample frame render (human-readable). | + +--- + +## Task 1: CellBuffer + content-stable checksum + +**Files:** +- Create: `examples/asteroid_tui/src/cellbuffer.jl` +- Modify: `examples/asteroid_tui/Project.toml` (add `SHA` stdlib dep) +- Modify: `examples/asteroid_tui/src/AsteroidTUI.jl` (include + using SHA) +- Test: `examples/asteroid_tui/test/test_cellbuffer.jl` + +- [ ] **Step 1: Add SHA dep.** Edit `Project.toml` `[deps]`: add `SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce"` and `[compat]` `SHA = "0.7"`. Verify resolve: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.resolve()'` → no error. + +- [ ] **Step 2: Write the failing test** (`test/test_cellbuffer.jl`): + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI: CellBuffer, clear!, put_char!, put_string!, checksum, to_text, nrows, ncols +using Test + +@testset "CellBuffer" begin + b = CellBuffer(3, 5) + @test nrows(b) == 3 && ncols(b) == 5 + @test all(==(' '), b.chars) + + put_string!(b, 1, 1, "AB") + put_char!(b, 2, 3, 'X'; fg=UInt8(9), bold=true) + @test b.chars[1,1] == 'A' && b.chars[1,2] == 'B' + @test b.chars[2,3] == 'X' && b.fg[2,3] == 0x09 && b.bold[2,3] + + # out-of-bounds writes are ignored, not errors + put_char!(b, 99, 99, 'Z') + put_string!(b, 1, 4, "WXYZ") # clips at col 5 + @test b.chars[1,4] == 'W' && b.chars[1,5] == 'X' + + # checksum is content-defined and stable (pin the literal once observed) + b2 = CellBuffer(3, 5); put_string!(b2, 1, 1, "AB"); put_char!(b2, 2, 3, 'X'; fg=UInt8(9), bold=true) + put_string!(b2, 1, 4, "WX") + @test checksum(b) == checksum(b2) # same content ⇒ same checksum + clear!(b2) + @test checksum(b) != checksum(b2) + @test checksum(CellBuffer(3,5)) == checksum(CellBuffer(3,5)) # empty stable + @test to_text(CellBuffer(1,3)) == " " +end +``` + +- [ ] **Step 3: Run, expect FAIL** (`UndefVarError: CellBuffer`): + `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()'` (or iterate faster with `include("test/test_cellbuffer.jl")` after `using AsteroidTUI`). + +- [ ] **Step 4: Implement** (`src/cellbuffer.jl`): + +```julia +# SPDX-License-Identifier: MIT +using SHA: sha256 + +""" + CellBuffer(rows, cols) + +Renderer-agnostic terminal frame: a `Char` grid plus 256-color foreground and a +bold mask. `chars[row, col]`, `row == 1` is the top. Both interactive renderers +(Tachikoma, future raw-ANSI) drain a `CellBuffer`; the CI golden test checksums it +without instantiating any renderer. +""" +struct CellBuffer + chars :: Matrix{Char} + fg :: Matrix{UInt8} # Color256 index; 0x00 == terminal default + bold :: BitMatrix +end + +CellBuffer(rows::Integer, cols::Integer) = + CellBuffer(fill(' ', rows, cols), zeros(UInt8, rows, cols), falses(rows, cols)) + +nrows(b::CellBuffer) = size(b.chars, 1) +ncols(b::CellBuffer) = size(b.chars, 2) +inbounds(b::CellBuffer, r::Integer, c::Integer) = 1 <= r <= nrows(b) && 1 <= c <= ncols(b) + +function clear!(b::CellBuffer) + fill!(b.chars, ' '); fill!(b.fg, 0x00); fill!(b.bold, false); return b +end + +function put_char!(b::CellBuffer, r::Integer, c::Integer, ch::Char; fg::UInt8=0x00, bold::Bool=false) + inbounds(b, r, c) || return b + @inbounds (b.chars[r, c] = ch; b.fg[r, c] = fg; b.bold[r, c] = bold) + return b +end + +function put_string!(b::CellBuffer, r::Integer, c::Integer, s::AbstractString; fg::UInt8=0x00, bold::Bool=false) + col = c; row = r + for ch in s + if ch == '\n' + row += 1; col = c; continue + end + put_char!(b, row, col, ch; fg=fg, bold=bold); col += 1 + end + return b +end + +# Canonical, version-stable byte encoding: dims, then chars (UTF-8), then fg, then bold. +function _canonical_bytes(b::CellBuffer) + io = IOBuffer() + write(io, UInt32(nrows(b))); write(io, UInt32(ncols(b))) + for ch in b.chars; write(io, codeunits(string(ch))); write(io, 0x00); end + write(io, b.fg) + write(io, UInt8.(b.bold)) + return take!(io) +end + +checksum(b::CellBuffer) = bytes2hex(sha256(_canonical_bytes(b))) +to_text(b::CellBuffer) = join((String(@view b.chars[r, :]) for r in 1:nrows(b)), '\n') +``` + +- [ ] **Step 5: Wire into module.** In `src/AsteroidTUI.jl`, inside `module AsteroidTUI`, add `using SHA` and `include("cellbuffer.jl")`; export `CellBuffer, clear!, put_char!, put_string!, checksum, to_text`. + +- [ ] **Step 6: Run, expect PASS.** `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()'` → CellBuffer testset green. + +- [ ] **Step 7: Commit.** `git add examples/asteroid_tui/Project.toml examples/asteroid_tui/src/cellbuffer.jl examples/asteroid_tui/src/AsteroidTUI.jl examples/asteroid_tui/test/test_cellbuffer.jl && git commit -m "feat(asteroid): renderer-agnostic CellBuffer + SHA checksum (#E)"` + +--- + +## Task 2: CellBackend (1 cell/grapheme) + +**Files:** Create `src/cellbackend.jl`; modify `src/AsteroidTUI.jl`; Test `test/test_cellbackend.jl`. + +- [ ] **Step 1: Failing test** (`test/test_cellbackend.jl`): + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI: CellBackend +import TextMeasure +using Test + +@testset "CellBackend" begin + b = CellBackend() + @test TextMeasure.measure(b, "rock") == 4.0 + @test TextMeasure.measure(b, "") == 0.0 + @test TextMeasure.measure(b, "a b") == 3.0 # space counts as a cell + m = TextMeasure.font_metrics(b) + @test (m.ascent, m.descent, m.line_advance) == (1.0, 0.0, 1.0) + # prepare/segments use it transparently + p = TextMeasure.prepare(b, "iron ore") + @test [s.kind for s in p.segments] == [:word, :space, :word] + @test p.segments[1].width == 4.0 && p.segments[3].width == 3.0 +end +``` + +- [ ] **Step 2: Run, expect FAIL** (`UndefVarError: CellBackend`). + +- [ ] **Step 3: Implement** (`src/cellbackend.jl`): + +```julia +# SPDX-License-Identifier: MIT +import TextMeasure +import Unicode + +""" + CellBackend() + +A zero-config measurement backend where every grapheme cluster is exactly one +terminal cell wide and a line advances exactly one row. This makes `shape_pack` +output land on integer cell coordinates, which is what the cell-grid silhouette +packing needs. (A fourth instance of CLAUDE.md's "subtype + two methods" pattern.) +""" +struct CellBackend <: TextMeasure.AbstractMeasurementBackend end + +TextMeasure.measure(::CellBackend, text::AbstractString) = + Float64(length(collect(Unicode.graphemes(text)))) + +TextMeasure.font_metrics(::CellBackend) = TextMeasure.FontMetrics(1.0, 0.0, 1.0) +``` + +> `Unicode` is a stdlib; add `Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5"` to `Project.toml` `[deps]` (and `Unicode = "1"` to `[compat]`) — Step 3a — then `Pkg.resolve()`. + +- [ ] **Step 4: Wire + run.** `include("cellbackend.jl")` and `export CellBackend` in the module; `Pkg.test()` → green. + +- [ ] **Step 5: Commit.** `git add -A examples/asteroid_tui && git commit -m "feat(asteroid): CellBackend — 1 cell/grapheme integer metrics (#E)"` + +--- + +## Task 3: Procedural prose pool (≥50 distinct) + +**Files:** Create `src/prose.jl`; modify module; Test `test/test_prose.jl`. + +- [ ] **Step 1: Failing test** (`test/test_prose.jl`): + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI: asteroid_prose, PROSE_VARIANTS +using Random +using Test + +@testset "prose pool" begin + @test PROSE_VARIANTS() >= 50 + rng = Xoshiro(7) + s = asteroid_prose(rng) + @test s isa String && length(split(s)) >= 6 + # deterministic by seed + @test asteroid_prose(Xoshiro(1)) == asteroid_prose(Xoshiro(1)) + # variety: 40 draws give many distinct strings + seen = Set(asteroid_prose(Xoshiro(i)) for i in 1:40) + @test length(seen) >= 30 + # no global RNG side effects + Random.seed!(123); a = rand(); Random.seed!(123); asteroid_prose(Xoshiro(9)); b = rand() + @test a == b +end +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** (`src/prose.jl`) — combinatorial template; the cartesian product of the lists below is ≫50, and `PROSE_VARIANTS` reports a conservative lower bound: + +```julia +# SPDX-License-Identifier: MIT +using Random: AbstractRNG + +const _CLASS = ("C-type", "S-type", "M-type", "carbonaceous", "silicate", + "metallic", "chondritic", "basaltic") +const _MATERIAL = ("iron-nickel", "olivine", "pyroxene", "regolith", "magnetite", + "ice-laced rock", "porous dust", "shock-veined ore") +const _TEMPER = ("ancient and cold", "scarred by impacts", "tumbling lazily", + "newly calved", "radar-bright", "spectrally dark") +const _CALLPREFIX = ("NX", "VG", "KR", "ZL", "QF", "BD") + +# Conservative count: class × material × temper (callsign/spin add far more). +PROSE_VARIANTS() = length(_CLASS) * length(_MATERIAL) * length(_TEMPER) + +""" + asteroid_prose(rng) -> String + +Deterministic-by-`rng` descriptive sentence for an asteroid's interior text. +Pulls only from `rng` (no global RNG access). +""" +function asteroid_prose(rng::AbstractRNG) + cls = rand(rng, _CLASS) + mat = rand(rng, _MATERIAL) + tmp = rand(rng, _TEMPER) + call = string(rand(rng, _CALLPREFIX), '-', lpad(rand(rng, 100:999), 3, '0')) + spin = round(rand(rng) * 0.4; digits = 2) + return "$cls drifter $call composed of $mat, $tmp, spinning at $spin rad per second." +end +``` + +- [ ] **Step 4: Wire + run** → green. **Step 5: Commit** `feat(asteroid): procedural prose pool (≥50 variants) (#E)`. + +--- + +## Task 4: pack_prose_into — silhouette text packing helper + +**Files:** Create `src/pack.jl`; module; Test `test/test_pack.jl`. Depends on Tasks 1–2. + +- [ ] **Step 1: Failing test** (`test/test_pack.jl`): + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI: CellBackend, pack_prose_into, PackedProse +import TextMeasure +using Silhouettes: asteroid_polygon +using Random +using Test + +@testset "pack_prose_into" begin + rng = Xoshiro(3) + poly = asteroid_polygon(rng; n=12, lumpiness=0.3) + prep = TextMeasure.prepare(CellBackend(), "iron rock spins fast cold dense ore here now") + pp = pack_prose_into(poly, prep; scale=18.0, min_chord_width=3.0) + @test pp isa PackedProse + @test pp.rows >= 3 && pp.cols >= 3 + @test !isempty(pp.cells) # (row, col, char) tuples + # all placed cells are inside the raster bounds + @test all(1 <= r <= pp.rows && 1 <= c <= pp.cols for (r, c, _ch) in pp.cells) + # determinism + pp2 = pack_prose_into(poly, prep; scale=18.0, min_chord_width=3.0) + @test pp.cells == pp2.cells +end +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** (`src/pack.jl`): + +```julia +# SPDX-License-Identifier: MIT +import TextMeasure +using TextMeasureLayouts: shape_pack, raster_chord_fn +using Silhouettes: rasterize +import GeometryBasics as GB + +""" + PackedProse(rows, cols, cells) + +A silhouette's packed interior text in **local cell coordinates** (row 1 = top of +the silhouette's bounding box). `cells :: Vector{Tuple{Int,Int,Char}}` is +`(row, col, char)` for every glyph, in reading order. +""" +struct PackedProse + rows :: Int + cols :: Int + cells :: Vector{Tuple{Int,Int,Char}} +end + +""" + pack_prose_into(polygon, prep; scale, min_chord_width=3.0) -> PackedProse + +Rasterize `polygon` (scaled to `scale` cells across its larger extent) to a cell +grid, then `shape_pack` the `:word` segments of `prep` (built with `CellBackend`) +into the silhouette at one row per line. Coordinates are integer cells. +""" +function pack_prose_into(polygon::Vector{GB.Point2{Float64}}, prep::TextMeasure.Prepared; + scale::Real, min_chord_width::Real=3.0) + xs = [p[1] for p in polygon]; ys = [p[2] for p in polygon] + span = max(maximum(xs) - minimum(xs), maximum(ys) - minimum(ys)) + span <= 0 && return PackedProse(1, 1, Tuple{Int,Int,Char}[]) + cell = span / scale # polygon-units per cell + raster = rasterize(polygon, cell) # BitMatrix, row 1 = top + cf = raster_chord_fn(raster, 1.0) # work in cell units (cell_size 1) + pk = shape_pack(prep, cf; line_advance = 1.0, min_chord_width = Float64(min_chord_width)) + cells = Tuple{Int,Int,Char}[] + for pl in pk.placements + seg = prep.segments[pl.segment_index] + seg.kind === :word || continue + row = round(Int, pl.y) # baseline y = band (ascent==1) + col0 = round(Int, pl.x) + 1 # left edge → 1-based col + for (k, ch) in enumerate(seg.str) + push!(cells, (row, col0 + k - 1, ch)) + end + end + return PackedProse(size(raster, 1), size(raster, 2), cells) +end +``` + +- [ ] **Step 4: Wire + run** → green. **Step 5: Commit** `feat(asteroid): pack_prose_into silhouette packing (#E)`. + +--- + +## Task 5: Entities + GameState + new_game + +**Files:** Create `src/entities.jl`, `src/input.jl`; module; (tests come with Task 6/7). + +- [ ] **Step 1: Implement entities** (`src/entities.jl`): + +```julia +# SPDX-License-Identifier: MIT +import GeometryBasics as GB +import TextMeasure + +const P2 = GB.Point2{Float64} + +mutable struct Ship + x::Float64; y::Float64 # cell coords (col, row), continuous + φ::Float64 # heading, radians (0 = up) + vx::Float64; vy::Float64 + charge::Int # 0..5 charge stage + alive::Bool + invuln::Int # remaining invulnerability ticks (>0 ⇒ blinking) +end + +mutable struct Asteroid + poly::Vector{P2} # unit-ish silhouette (Silhouettes frame) + x::Float64; y::Float64 # center in cell coords + vx::Float64; vy::Float64 + ω::Float64 # spin rad/tick + θ::Float64 # accumulated rotation + radius::Float64 # cell radius (for collision + scale) + prep::TextMeasure.Prepared # measured prose (measure once!) + age::Int # ticks since spawn (re-raster cadence) +end + +mutable struct Shard + poly::Vector{P2} + x::Float64; y::Float64 + vx::Float64; vy::Float64 + prep::TextMeasure.Prepared # subprep slice — NOT re-measured + ttl::Int # ticks to live + radius::Float64 +end + +mutable struct Beam + active::Bool + x::Float64; y::Float64 # origin (ship tip) + φ::Float64 + length::Int # cells (onomatopoeia repeats) + ttl::Int +end +``` + +- [ ] **Step 2: Implement input** (`src/input.jl`): + +```julia +# SPDX-License-Identifier: MIT +""" + Input(; thrust=false, left=false, right=false, fire=false, debug=false, quit=false) + +One tick's intent, decoupled from any key encoding. `fire` held across ticks grows +the charge; releasing it (a tick with `fire=false` after `fire=true`) launches. +""" +Base.@kwdef struct Input + thrust::Bool = false + left::Bool = false + right::Bool = false + fire::Bool = false + debug::Bool = false + quit::Bool = false +end + +""" + ScriptedInput(seq::Vector{Input}) + +Deterministic input source for the headless golden test. `next_input!` returns the +next entry, repeating the last once exhausted. +""" +mutable struct ScriptedInput + seq::Vector{Input} + i::Int +end +ScriptedInput(seq::Vector{Input}) = ScriptedInput(seq, 0) +function next_input!(s::ScriptedInput) + s.i = min(s.i + 1, length(s.seq)) + return isempty(s.seq) ? Input() : s.seq[s.i] +end +``` + +- [ ] **Step 3: Implement GameState + new_game** (in `src/game.jl`, the rest filled by Tasks 6–8): + +```julia +# SPDX-License-Identifier: MIT +using Random: AbstractRNG, Xoshiro +import TextMeasure +using Silhouettes: asteroid_polygon + +mutable struct GameState + width::Int; height::Int # buffer cols, rows + ship::Ship + asteroids::Vector{Asteroid} + shards::Vector{Shard} + beam::Beam + rng::Xoshiro + tick_count::Int + debug::Bool + prev_fire::Bool # for release-to-launch edge detection + last_hit_glyphs::Vector{String} # words of the most recently fractured asteroid (for tests) +end + +const CHARGE_MAX = 5 +const INVULN_TICKS = 120 # ~2s at 60fps +const RERASTER_EVERY = 5 + +function _spawn_asteroid(rng::AbstractRNG, width, height) + poly = asteroid_polygon(rng; n = rand(rng, 8:16), lumpiness = 0.2 + 0.5 * rand(rng)) + prep = TextMeasure.prepare(CellBackend(), asteroid_prose(rng)) + radius = 6.0 + 6.0 * rand(rng) + return Asteroid(poly, + rand(rng) * width, rand(rng) * height, + (rand(rng) - 0.5) * 0.6, (rand(rng) - 0.5) * 0.6, + (rand(rng) - 0.5) * 0.8 / 60, # ω in rad/tick (~[-0.4,0.4] rad/s) + 0.0, radius, prep, 0) +end + +""" + new_game(rng; width=120, height=40, n_asteroids=5) -> GameState + +Seeded initial state. All randomness flows from `rng` (pass `Xoshiro(seed)` for a +reproducible game — the golden test relies on this). +""" +function new_game(rng::Xoshiro = Xoshiro(0); width=120, height=40, n_asteroids=5) + ship = Ship(width/2, height/2, 0.0, 0.0, 0.0, 0, true, 0) + asteroids = [_spawn_asteroid(rng, width, height) for _ in 1:n_asteroids] + beam = Beam(false, 0.0, 0.0, 0.0, 0, 0) + return GameState(width, height, ship, asteroids, Shard[], beam, rng, 0, false, + false, String[]) +end +``` + +- [ ] **Step 4: Wire all includes** in `src/AsteroidTUI.jl` (order: cellbuffer, cellbackend, prose, pack, entities, input, game, draw, render_tachikoma) and export `GameState, new_game, tick!, draw!, Input, ScriptedInput`. **Step 5: Commit** `feat(asteroid): entities, input, GameState/new_game (#E)`. + +--- + +## Task 6: tick! — physics, charge, beam (no fracture yet) + +**Files:** Modify `src/game.jl`; Test `test/test_game.jl`. + +- [ ] **Step 1: Failing test** (`test/test_game.jl`): + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI: new_game, tick!, Input, CHARGE_MAX +using Random +using Test + +@testset "tick! physics" begin + g = new_game(Xoshiro(42); width=120, height=40, n_asteroids=3) + x0, y0 = g.ship.x, g.ship.y + # thrust changes velocity then position; world wraps (stays in-bounds) + for _ in 1:5; tick!(g, Input(thrust=true)); end + @test (g.ship.vx, g.ship.vy) != (0.0, 0.0) + @test 0 <= g.ship.x <= g.width && 0 <= g.ship.y <= g.height + # rotation + φ0 = g.ship.φ; tick!(g, Input(left=true)); @test g.ship.φ != φ0 + # charge ramps while fire held, caps at CHARGE_MAX + g2 = new_game(Xoshiro(1)) + for _ in 1:20; tick!(g2, Input(fire=true)); end + @test g2.ship.charge == CHARGE_MAX + # release launches a beam and resets charge + tick!(g2, Input(fire=false)) + @test g2.beam.active && g2.ship.charge == 0 + # asteroids advanced + rotated + g3 = new_game(Xoshiro(5)); a = g3.asteroids[1]; θ0 = a.θ + tick!(g3, Input()); @test g3.asteroids[1].θ != θ0 + # debug toggles on a debug-edge + g4 = new_game(Xoshiro(2)); @test !g4.debug; tick!(g4, Input(debug=true)); @test g4.debug + @test g3.tick_count == 1 +end +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement `tick!`** (append to `src/game.jl`). Physics in cell units; world wraps toroidally; charge edge-detected via `prev_fire`. (Collision/fracture added in Task 7 — call `_resolve_collisions!(g)` which is a no-op stub here, replaced next task.) + +```julia +const THRUST = 0.05 +const TURN = 0.12 +const FRICTION = 0.98 + +_wrap(v, hi) = mod(v, hi) + +function _advance_ship!(g::GameState, in::Input) + s = g.ship + in.left && (s.φ -= TURN) + in.right && (s.φ += TURN) + if in.thrust + s.vx += THRUST * sin(s.φ); s.vy -= THRUST * cos(s.φ) + end + s.vx *= FRICTION; s.vy *= FRICTION + s.x = _wrap(s.x + s.vx, g.width); s.y = _wrap(s.y + s.vy, g.height) + s.invuln > 0 && (s.invuln -= 1) +end + +function _advance_asteroids!(g::GameState) + for a in g.asteroids + a.x = _wrap(a.x + a.vx, g.width); a.y = _wrap(a.y + a.vy, g.height) + a.θ += a.ω; a.age += 1 + end + for sh in g.shards + sh.x = _wrap(sh.x + sh.vx, g.width); sh.y = _wrap(sh.y + sh.vy, g.height) + sh.ttl -= 1 + end + filter!(sh -> sh.ttl > 0, g.shards) +end + +function _handle_charge_and_beam!(g::GameState, in::Input) + s = g.ship + if in.fire + s.charge = min(s.charge + 1, CHARGE_MAX) + elseif g.prev_fire && s.charge > 0 # release edge ⇒ launch + g.beam = Beam(true, s.x, s.y, s.φ, 4 + 6 * s.charge, 6) + s.charge = 0 + end + g.prev_fire = in.fire + if g.beam.active + g.beam.ttl -= 1 + g.beam.ttl <= 0 && (g.beam = Beam(false, 0.0, 0.0, 0.0, 0, 0)) + end +end + +_resolve_collisions!(g::GameState) = g # replaced in Task 7 + +""" + tick!(g, input) -> g + +Advance the game one frame. Pure w.r.t. the terminal — mutates only `g`. Uses only +`g.rng` for any randomness (respawns), so a seeded `new_game` + scripted inputs is +fully reproducible. +""" +function tick!(g::GameState, in::Input) + in.debug && (g.debug = !g.debug) + _advance_ship!(g, in) + _advance_asteroids!(g) + _handle_charge_and_beam!(g, in) + _resolve_collisions!(g) + g.tick_count += 1 + return g +end +``` + +> Note the debug-toggle test sends `Input(debug=true)` exactly once; if you later drive debug from a held key, edge-detect it like `fire`. For the scripted golden, debug is sent as single-tick pulses. + +- [ ] **Step 4: Run → green. Step 5: Commit** `feat(asteroid): tick! physics, charge, beam (#E)`. + +--- + +## Task 7: Collision + word-boundary fracture (glyph-preservation) + +**Files:** Modify `src/game.jl` (replace `_resolve_collisions!`); Test `test/test_fracture.jl`. + +- [ ] **Step 1: Failing test** (`test/test_fracture.jl`) — the operational "legible" definition: every original word appears once, in order, across the two shards: + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI: new_game, tick!, Input, fracture_asteroid!, GameState +import TextMeasure +import GeometryBasics as GB +using Random +using Test + +# helper: words (in order) of a Prepared +words(p) = [s.str for s in p.segments if s.kind === :word] + +@testset "word-boundary fracture preserves glyphs" begin + g = new_game(Xoshiro(11); n_asteroids=1) + a = g.asteroids[1] + original = words(a.prep) + impact = GB.Point2{Float64}(a.x, a.y) + n_before = length(g.shards) + fracture_asteroid!(g, 1, impact) + @test isempty(g.asteroids) # the hit asteroid is removed + @test length(g.shards) >= 2 # at least two shard-prose chunks + # concatenating shard words in shard order reproduces the original word order + rebuilt = vcat((words(sh.prep) for sh in g.shards)...) + @test rebuilt == original # no drops, no dups, in order + @test g.last_hit_glyphs == original +end +``` + +- [ ] **Step 2: Run, expect FAIL** (`UndefVarError: fracture_asteroid!`). + +- [ ] **Step 3: Implement** `_resolve_collisions!` + `fracture_asteroid!` (append to `src/game.jl`): + +```julia +using TextMeasure: subprep +using Silhouettes: voronoi_shatter +import GeometryBasics as GB + +# Split a Prepared's segment range into ~`n` contiguous chunks at :word boundaries, +# so that concatenating the chunks' words reproduces the original word order exactly. +function _word_boundary_splits(prep::TextMeasure.Prepared, n::Int) + word_idx = [i for (i, s) in enumerate(prep.segments) if s.kind === :word] + nw = length(word_idx) + nw == 0 && return UnitRange{Int}[1:length(prep.segments)] + n = clamp(n, 1, nw) + bounds = Int[] + for k in 1:(n - 1) + push!(bounds, word_idx[clamp(round(Int, k * nw / n), 1, nw)]) # split before this word + end + ranges = UnitRange{Int}[] + lo = 1 + for b in bounds + push!(ranges, lo:(b - 1)); lo = b + end + push!(ranges, lo:length(prep.segments)) + return ranges +end + +""" + fracture_asteroid!(g, idx, impact) + +Remove asteroid `idx`, fracture its silhouette with `voronoi_shatter` seeded at +`impact`, and re-pack each shard with a `subprep` slice of the asteroid's already +-measured prose (no re-measurement). The slices tile the segment range, so every +glyph survives in exactly one shard, in original order. +""" +function fracture_asteroid!(g::GameState, idx::Int, impact::GB.Point2{Float64}) + a = g.asteroids[idx] + n_shards = 2 + (length([s for s in a.prep.segments if s.kind === :word]) >= 6 ? 2 : 0) + polys = voronoi_shatter(a.poly, GB.Point2{Float64}(0.0, 0.0); n_shards = n_shards) + isempty(polys) && (polys = [a.poly]) + ranges = _word_boundary_splits(a.prep, length(polys)) + deleteat!(g.asteroids, idx) + g.last_hit_glyphs = [s.str for s in a.prep.segments if s.kind === :word] + for (poly, r) in zip(polys, ranges) + sp = subprep(a.prep, r) + push!(g.shards, Shard(poly, a.x, a.y, + a.vx + (rand(g.rng) - 0.5) * 0.4, + a.vy + (rand(g.rng) - 0.5) * 0.4, + sp, 90, a.radius / 2)) + end + return g +end + +function _resolve_collisions!(g::GameState) + g.beam.active || return g + bx, by, φ = g.beam.x, g.beam.y, g.beam.φ + dirx, diry = sin(φ), -cos(φ) + for idx in length(g.asteroids):-1:1 + a = g.asteroids[idx] + for t in 0:g.beam.length # sample along the beam + px, py = bx + dirx * t, by + diry * t + if hypot(px - a.x, py - a.y) <= a.radius + fracture_asteroid!(g, idx, GB.Point2{Float64}(px - a.x, py - a.y)) + break + end + end + end + return g +end +``` + +- [ ] **Step 4: Run → green. Step 5: Commit** `feat(asteroid): collision + word-boundary fracture via subprep (#E)`. + +--- + +## Task 8: Respawn + invulnerability + +**Files:** Modify `src/game.jl`; extend `test/test_game.jl`. + +- [ ] **Step 1: Add failing test** (append to `test/test_game.jl`): + +```julia +@testset "respawn + invuln" begin + using AsteroidTUI: kill_ship!, ship_visible + g = new_game(Xoshiro(8)) + kill_ship!(g) + @test !g.ship.alive + # respawn after the death timer; gains invulnerability + for _ in 1:65; tick!(g, Input()); end + @test g.ship.alive && g.ship.invuln > 0 + # invuln blink: visibility alternates over ticks + vis = [ (tick!(g, Input()); ship_visible(g)) for _ in 1:8 ] + @test any(vis) && any(!, vis) +end +``` + +- [ ] **Step 2: Implement** (append to `src/game.jl`): add a `death_timer::Int` field path via a module-level mutable on `Ship` — simplest is to add `respawn_in::Int` to `GameState` (modify the struct in Task 5 to include `respawn_in::Int` initialized to 0; if executing strictly in order, add it now and update `new_game`). + +```julia +function kill_ship!(g::GameState) + g.ship.alive = false + g.respawn_in = 60 # ~1s before respawn + return g +end + +# call inside tick! BEFORE _advance_ship!: if dead, count down then respawn +function _handle_respawn!(g::GameState) + g.ship.alive && return g + g.respawn_in -= 1 + if g.respawn_in <= 0 + g.ship = Ship(g.width/2, g.height/2, 0.0, 0.0, 0.0, 0, true, INVULN_TICKS) + end + return g +end + +# blink at ~3Hz: visible 10 ticks on / 10 off while invulnerable +ship_visible(g::GameState) = g.ship.alive && (g.ship.invuln == 0 || (g.ship.invuln ÷ 10) % 2 == 0) +``` + +Insert `_handle_respawn!(g)` as the first line of `tick!`'s body (before `in.debug`), and guard `_advance_ship!`/charge on `g.ship.alive`. Export `kill_ship!, ship_visible`. + +- [ ] **Step 3: Run → green. Step 4: Commit** `feat(asteroid): ship death/respawn + invuln blink (#E)`. + +--- + +## Task 9: draw! — paint a full frame into the CellBuffer + +**Files:** Create `src/draw.jl`; Test `test/test_draw.jl`. Colors are `Color256` indices (UInt8): asteroid prose 250 (grey), ship 51 (cyan), beam 226 (yellow), stat tags 244, debug 45. + +- [ ] **Step 1: Failing test** (`test/test_draw.jl`): + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI: new_game, tick!, Input, draw!, CellBuffer, to_text, ship_visible +using Random +using Test + +@testset "draw!" begin + g = new_game(Xoshiro(21); width=80, height=24, n_asteroids=3) + for _ in 1:10; tick!(g, Input(thrust=true)); end + buf = CellBuffer(g.height, g.width) + draw!(buf, g) + txt = to_text(buf) + @test length(txt) > g.width # something was drawn + @test count(!=(' '), buf.chars) > 0 + # debug overlay adds cyan bboxes when on + g.debug = true + buf2 = CellBuffer(g.height, g.width); draw!(buf2, g) + @test count(==(UInt8(45)), buf2.fg) >= count(==(UInt8(45)), buf.fg) + # determinism: same state ⇒ same buffer + bufA = CellBuffer(g.height, g.width); draw!(bufA, g) + bufB = CellBuffer(g.height, g.width); draw!(bufB, g) + @test bufA.chars == bufB.chars && bufA.fg == bufB.fg +end +``` + +- [ ] **Step 2: Run, expect FAIL.** + +- [ ] **Step 3: Implement** (`src/draw.jl`). Each asteroid/shard: `pack_prose_into` (its `prep`, `scale ≈ 2·radius`), then blit cells at `(center_row - rows÷2 + r, center_col - cols÷2 + c)`. Ship: a small figlet/wedge glyph + charge glyph at the tip. Beam: repeated `"PEW "` along `φ` for `beam.length` cells. Stat tags: `Printf.@sprintf("┌─ d:%dm v:%.2fµ ─┐", …)` above each asteroid. Debug: draw a `fg=45` cell at each placed glyph's position. + +```julia +# SPDX-License-Identifier: MIT +import Printf +import GeometryBasics as GB + +const COL_PROSE = 0xfa # 250 +const COL_SHIP = 0x33 # 51 +const COL_BEAM = 0xe2 # 226 +const COL_TAG = 0xf4 # 244 +const COL_DEBUG = 0x2d # 45 +const CHARGE_GLYPH = (' ', '·', '*', '─', '\\', '✸') # index = charge stage (1-based: charge+1) + +function _blit_packed!(buf::CellBuffer, pp, cx::Real, cy::Real; fg, debug::Bool) + r0 = round(Int, cy) - pp.rows ÷ 2 + c0 = round(Int, cx) - pp.cols ÷ 2 + for (r, c, ch) in pp.cells + put_char!(buf, r0 + r - 1, c0 + c - 1, ch; fg = fg) + debug && put_char!(buf, r0 + r - 1, c0 + c - 1, ch; fg = COL_DEBUG) + end +end + +function _draw_asteroid!(buf::CellBuffer, a, debug::Bool) + pp = pack_prose_into(a.poly, a.prep; scale = max(4.0, 2 * a.radius), min_chord_width = 3.0) + _blit_packed!(buf, pp, a.x, a.y; fg = COL_PROSE, debug = debug) + tag = Printf.@sprintf("┌─ d:%03dm v:%.2fµ ─┐", round(Int, a.radius * 10), hypot(a.vx, a.vy)) + put_string!(buf, round(Int, a.y) - pp.rows ÷ 2 - 1, round(Int, a.x) - length(tag) ÷ 2, tag; fg = COL_TAG) +end + +function _draw_ship!(buf::CellBuffer, g) + ship_visible(g) || return + s = g.ship + put_char!(buf, round(Int, s.y), round(Int, s.x), '▲'; fg = COL_SHIP, bold = true) + if s.charge > 0 + put_char!(buf, round(Int, s.y) - 1, round(Int, s.x), CHARGE_GLYPH[s.charge + 1]; fg = COL_BEAM, bold = true) + end +end + +function _draw_beam!(buf::CellBuffer, g) + g.beam.active || return + dirx, diry = sin(g.beam.φ), -cos(g.beam.φ) + word = "PEW " + for t in 1:g.beam.length + ch = word[(t - 1) % length(word) + 1] + put_char!(buf, round(Int, g.beam.y + diry * t), round(Int, g.beam.x + dirx * t), ch; fg = COL_BEAM) + end +end + +""" + draw!(buf, g) -> buf + +Paint the whole game into `buf` (cleared first). Pure: no terminal I/O. +""" +function draw!(buf::CellBuffer, g::GameState) + clear!(buf) + for a in g.asteroids; _draw_asteroid!(buf, a, g.debug); end + for sh in g.shards + pp = pack_prose_into(sh.poly, sh.prep; scale = max(4.0, 2 * sh.radius), min_chord_width = 3.0) + _blit_packed!(buf, pp, sh.x, sh.y; fg = COL_PROSE, debug = g.debug) + end + _draw_beam!(buf, g) + _draw_ship!(buf, g) + return buf +end +``` + +- [ ] **Step 4: Run → green. Step 5: Commit** `feat(asteroid): draw! frame compositor (#E)`. + +--- + +## Task 10: Golden frame test (the deliverable's regression anchor) + +**Files:** Create `test/test_golden.jl`, `test/golden/frame60.sha256`, `test/golden/frame60.txt`. Depends on all prior tasks. + +- [ ] **Step 1: Write the scenario + test** (`test/test_golden.jl`). The script must force at least one hit→fracture so the golden exercises `subprep`/`voronoi_shatter`. Tune the fire/aim ticks during bring-up so a fracture actually occurs (assert `length(g.shards) >= 2` at the end — this is part of the test, not just the checksum). + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI: new_game, tick!, draw!, Input, ScriptedInput, next_input!, CellBuffer, + checksum, to_text +using Random +using Test + +const GOLDEN_DIR = joinpath(@__DIR__, "golden") + +# Deterministic 60-tick scenario: aim, charge, fire to hit, then drift. +function _scripted_seq() + seq = Input[] + for _ in 1:6; push!(seq, Input(right=true)); end # aim + for _ in 1:10; push!(seq, Input(fire=true)); end # charge to max + push!(seq, Input(fire=false)) # launch + for _ in 1:43; push!(seq, Input(thrust=(rand(Xoshiro(99)) > 0.5))); end + return seq +end + +function _run_golden() + g = new_game(Xoshiro(2024); width=120, height=40, n_asteroids=5) + si = ScriptedInput(_scripted_seq()) + buf = CellBuffer(g.height, g.width) + for _ in 1:60 + tick!(g, next_input!(si)) + end + draw!(buf, g) + return g, buf +end + +@testset "golden frame (60 ticks)" begin + g, buf = _run_golden() + @test length(g.shards) >= 2 # a fracture happened + cs = checksum(buf) + golden_path = joinpath(GOLDEN_DIR, "frame60.sha256") + if get(ENV, "UPDATE_GOLDEN", "") == "1" + mkpath(GOLDEN_DIR) + write(golden_path, cs) + write(joinpath(GOLDEN_DIR, "frame60.txt"), to_text(buf)) + end + @test isfile(golden_path) + @test cs == strip(read(golden_path, String)) # regression anchor + + # glyph preservation across the run: every fractured word survives in order + if !isempty(g.last_hit_glyphs) + shard_words = vcat(([s.str for s in sh.prep.segments if s.kind === :word] for sh in g.shards)...) + @test all(w -> w in shard_words, g.last_hit_glyphs) + end +end +``` + +- [ ] **Step 2: Generate the golden once** (after the scenario reliably fractures): + `UPDATE_GOLDEN=1 julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()'` + Confirm `test/golden/frame60.sha256` and `frame60.txt` were written and look sane (open `frame60.txt`). + +- [ ] **Step 3: Re-run WITHOUT the env var** → golden matches (PASS). Run twice to confirm stability. + +- [ ] **Step 4: Commit** `git add examples/asteroid_tui/test/test_golden.jl examples/asteroid_tui/test/golden && git commit -m "test(asteroid): committed golden 60-tick frame + glyph preservation (#E)"` + +--- + +## Task 11: Tachikoma renderer + run.jl (interactive; NOT unit-tested — tier-2/3) + +**Files:** Create `src/render_tachikoma.jl`, `run.jl`. **Flag for the orchestrator's human check** (≥30fps, respawn flash, invuln blink, debug `?` overlay live). + +- [ ] **Step 1: Implement the drain + loop** (`src/render_tachikoma.jl`). Drains the `CellBuffer` into a Tachikoma `Buffer` and presents it; reads keys into `Input`. Uses only verified primitives (`Buffer(Rect)`, `set_char!`, `Style`, `Color256`, `Terminal`). The exact key-read / present wiring is validated live during this task (interactive). + +```julia +# SPDX-License-Identifier: MIT +import Tachikoma as TK + +# Drain a renderer-agnostic CellBuffer into a Tachikoma Buffer (1-based x=col, y=row). +function drain_to_tachikoma!(tbuf, cb::CellBuffer) + for r in 1:nrows(cb), c in 1:ncols(cb) + ch = cb.chars[r, c] + ch == ' ' && cb.fg[r, c] == 0x00 && continue + style = TK.Style(; fg = TK.Color256(Int(cb.fg[r, c])), bold = cb.bold[r, c]) + TK.set_char!(tbuf, c, r, ch, style) + end + return tbuf +end + +# Map a Tachikoma KeyEvent to our Input. Adjust key names against the live API +# during bring-up (interactive, human-verified). +function key_to_input(ev)::Input + k = ev # placeholder mapping resolved live + return Input() # replaced with real key dispatch in this task +end + +""" + run_game(; width=120, height=40, seed=0) + +Interactive entry. Builds a Tachikoma Terminal, runs the game loop at ~60fps: +read key → tick! → draw! into CellBuffer → drain → present. Linux/macOS only. +""" +function run_game(; width=120, height=40, seed=0) + g = new_game(Xoshiro(seed); width=width, height=height) + cb = CellBuffer(height, width) + term = TK.Terminal(; size = TK.Rect(0, 0, width, height)) + # main loop: see Tachikoma's app/event API; pseudocode resolved live: + # while !quit + # in = poll_input(term) # → key_to_input + # tick!(g, in); draw!(cb, g) + # tbuf = TK.Buffer(TK.Rect(0,0,width,height)); drain_to_tachikoma!(tbuf, cb) + # present(term, tbuf); sleep(1/60) + # end + return g +end +``` + +> Implementation note: prefer Tachikoma's high-level `TK.@tachikoma_app` / `TK.Model` if it cleanly supports a fixed-timestep redraw; otherwise use the low-level `Terminal` + raw key polling. Either way the **game core and golden test do not depend on this file**. Capture a real frame for the PR via `to_text(cb)` (see Verify). + +- [ ] **Step 2: `run.jl`:** + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI +AsteroidTUI.run_game(; seed = parse(Int, get(ENV, "SEED", "0"))) +``` + +- [ ] **Step 3: Manual smoke** (not CI): `julia --project=examples/asteroid_tui examples/asteroid_tui/run.jl` in a real terminal; verify it renders and quits cleanly. **Commit** `feat(asteroid): Tachikoma renderer + run.jl (interactive, tier-2/3) (#E)`. + +--- + +## Task 12: Finalize — README, license headers, sample frame, suite green + +**Files:** `README.md`, `test/runtests.jl`, all `src/*.jl`. + +- [ ] **Step 1: `test/runtests.jl` aggregates** every `test_*.jl`: + +```julia +# SPDX-License-Identifier: MIT +using Test +@testset "AsteroidTUI" begin + for f in ("test_cellbuffer.jl","test_cellbackend.jl","test_prose.jl","test_pack.jl", + "test_game.jl","test_fracture.jl","test_draw.jl","test_golden.jl") + include(f) + end +end +``` + +- [ ] **Step 2: License audit.** Every new `.jl` (and `run.jl`) starts with `# SPDX-License-Identifier: MIT`. Verify: `! grep -rL 'SPDX-License-Identifier: MIT' examples/asteroid_tui/src examples/asteroid_tui/test examples/asteroid_tui/run.jl` returns nothing. + +- [ ] **Step 3: README** — finalize run instructions, the *measure-once-layout-many* pitch, the Tachikoma-needs-1.12 note, and embed the committed `test/golden/frame60.txt` as a sample. + +- [ ] **Step 4: Full suite green + capture sample frame.** + `mkdir -p test-logs && julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` — grep for `Test Summary` / `Error`/`Fail`. + Write a sample frame for the PR: `UPDATE_GOLDEN=1` run already produced `test/golden/frame60.txt`; attach that path in the "PR opened" message. + +- [ ] **Step 5: Confirm Manifest is NOT staged** (`git status` must not list `examples/asteroid_tui/Manifest.toml`; it is gitignored per milestone convention). **Commit** `chore(asteroid): runtests aggregator, README, license audit (#E)`. + +--- + +## Self-review checklist (run before declaring the plan done) + +1. **Spec coverage:** silhouette+prose pack (Task 4/9), figlet display type (Task 9 ship/charge; note: full ARWING figlet headline optional — beam uses `PEW`), word-boundary fracture via `subprep` (Task 7), ≥50 prose templates (Task 3), low-Hz rotation reflow (asteroid `ω` + re-pack each draw, Task 9), charge 5 stages (Task 6/9), respawn+invuln (Task 8), debug overlay (Task 9), headless 60-tick golden + glyph preservation (Task 10). ✔ +2. **Interactive done-whens (NOT unit-testable, flag for human):** ≥30fps, live respawn flash, invuln blink, `?` debug toggle, beam length scaling on screen — Task 11; flagged in PR. ✔ +3. **Type consistency:** `Placement(segment_index,x,y)`, `Prepared.segments[i].str`, `FontMetrics(ascent,descent,line_advance)`, `Point2{Float64}`, `shape_pack(...; line_advance, min_chord_width)` — all match probed signatures. `tick!`/`draw!`/`new_game`/`fracture_asteroid!`/`pack_prose_into` names consistent across tasks. ✔ +4. **Conventions:** Manifest gitignored; `[extras] Test`+`[targets]`; SPDX headers; assertions on the `CellBuffer`/checksum and computed structures, never terminal pixels; golden is a floor/anchor not a brittle hard count. ✔ diff --git a/docs/superpowers/plans/2026-05-29-E-asteroid-tui-interactive-polish.md b/docs/superpowers/plans/2026-05-29-E-asteroid-tui-interactive-polish.md new file mode 100644 index 0000000..102ccfa --- /dev/null +++ b/docs/superpowers/plans/2026-05-29-E-asteroid-tui-interactive-polish.md @@ -0,0 +1,991 @@ +# Asteroid TUI — Interactive Controls & Collision Polish — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `examples/asteroid_tui/` genuinely playable — twin-stick controls (WASD strafe + mouse aim), working asteroid/ship collisions with death+respawn, and a self-replenishing field — without regressing the engine showcase or the headless tests. + +**Architecture:** Pure `tick!(state, input)` / `draw!(buf, state)` over a renderer-agnostic `CellBuffer` is preserved. Input becomes a stateful twin-stick layer: a held-key map (decayed by a pure `sweep_stale!`) + last-known cursor, folded into one `Input` per frame; the sim turns the cursor into a heading from the live ship position. Collisions use one wrap-aware signed-delta helper. + +**Tech Stack:** Julia 1.12, Tachikoma 2.1.0 (terminal/events — internal API, `TK.`-qualified), Silhouettes (`voronoi_shatter`/`asteroid_polygon`), TextMeasure (`subprep`/`prepare`). Demo tests run via `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()'`. + +**Spec:** `docs/superpowers/specs/2026-05-29-E-asteroid-tui-interactive-polish-design.md` (converged, 5 review rounds). + +**Source of truth on terminal/geometry facts (verified on disk):** +- Convention: `φ=0` is up, clockwise; `dir(φ)=(sin φ, −cos φ)` (`game.jl:63`, `draw.jl:185`). +- Visual-space aim multiplies the **row** delta by ~2 (cell aspect height/width≈2): `atan(dx, −(dy·2))`. +- Tachikoma: `KeyEvent(key::Symbol, char::Char, action)`, actions `key_press/key_repeat/key_release`; `MouseEvent(x,y,button,action,…)`, buttons incl. `mouse_left/mouse_none`, actions `mouse_press/mouse_release/mouse_move/mouse_drag`; held keys arrive as `key_repeat`; raw `TK.poll_event` delivers all actions (no App-framework gating). All `TK.`-qualified (unexported). + +--- + +## Cross-cutting constraint: Julia module compilation + +`examples/asteroid_tui/src/AsteroidTUI.jl` compiles as one module on `using AsteroidTUI`. Removing `Input.thrust` simultaneously breaks `input.jl`, `game.jl` (`_advance_ship!`/`tick!`), `render_tachikoma.jl` (`_poll_input`), **and** three test files. There is no smaller compilable step for the contract switch, so **Task 2 is one atomic commit** that changes all of them together. Tasks 1, 4–9 are additive or localized and stay small. + +## File structure + +| File | Responsibility after this work | +|---|---| +| `src/input.jl` | `Input` value type (twin-stick fields + `aim` cursor) + `ScriptedInput` | +| `src/game.jl` | sim: movement, `aim_heading`, `_wrap_delta`, collisions (asteroid/ship), death/respawn, replenish, fracture frame-conversion, `tick!` | +| `src/render_tachikoma.jl` | `InputState` + pure `sweep_stale!`/`fold_input` + `_poll_input!` + `run_game` wiring + loop | +| `src/draw.jl` | `_draw_ship!` directional glyph; no leader/plume | +| `src/entities.jl` | unchanged (`Ship.φ` already exists) | +| `test/test_input.jl` | **new** — headless unit tests for `sweep_stale!`/`fold_input` (`aim_heading` tested in `test_game.jl`) | +| `test/runtests.jl` | add `"test_input.jl"` to the include tuple | +| `test/test_game.jl`, `test_gameloop.jl`, `test_draw.jl`, `test_fracture.jl` | updated field set + new behavior tests | +| `test/golden/frame60.{sha256,txt}` | regenerated (driven by the `draw.jl` visual changes: glyph + plume + leader cut) | +| `run.jl` | rewrite the player-facing controls banner (`:3-5`) for twin-stick | + +--- + +## Task 0: Environment bootstrap (`[sources]`) + baseline green + +This branch predates the #J `[sources]` fix and `Project.toml` has no `[sources]` table, so `Pkg.test()` currently fails at instantiate. Fix it before any TDD. + +**Files:** +- Modify: `examples/asteroid_tui/Project.toml` + +- [ ] **Step 1: Add a `[sources]` table** (after `[deps]`, before `[compat]`). This is the Julia 1.11+ local-path-dep mechanism the #J fix applied to the other demos on `main`; this branch predates it, so add it here (justified on its own — it makes a plain `Pkg.instantiate()`/`Pkg.test()` resolve the local packages): + +```toml +# Local-path deps so a plain `Pkg.instantiate()` / `Pkg.test()` works without a +# manual `Pkg.develop` step (Julia 1.11+ [sources] feature). +[sources] +TextMeasure = { path = "../.." } +TextMeasureLayouts = { path = "../layouts" } +Silhouettes = { path = "../silhouettes" } +``` + +- [ ] **Step 2: Run the suite to establish a green baseline** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.instantiate(); Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: instantiate succeeds; **all existing tests pass** (this is the pre-change baseline). Confirm exit code is 0 (`echo $?`), do not trust a piped tail. + +- [ ] **Step 3: Commit** + +```bash +git add examples/asteroid_tui/Project.toml +git commit -m "build(#E): add [sources] so asteroid_tui Pkg.test() instantiates" +``` + +> `examples/asteroid_tui/Manifest.toml` stays **gitignored** — do not stage it. + +--- + +## Task 1: Pure geometry helpers — `_wrap_delta` and `aim_heading` + +Additive (no `Input` change yet), so the module stays compilable and these get clean unit tests first. + +**Files:** +- Modify: `examples/asteroid_tui/src/game.jl` (insert after `_wrap`, ~line 28) +- Modify: `examples/asteroid_tui/test/test_game.jl` (append a testset) +- Modify: `examples/asteroid_tui/src/AsteroidTUI.jl` is **not** needed (tests import unexported names by `using AsteroidTUI: _wrap_delta, aim_heading`) + +- [ ] **Step 1: Write the failing tests.** First **extend `test_game.jl`'s top `using` line** so the helpers are imported before any use anywhere in the file (later tasks call `aim_heading` from the top `tick! physics` testset — a bottom `using` would run too late): + +```julia +using AsteroidTUI: new_game, tick!, Input, CHARGE_MAX, kill_ship!, ship_visible, + _wrap_delta, aim_heading +``` + +Then append these testsets (no separate `using`): + +```julia +@testset "wrap-aware delta" begin + W, H = 100.0, 40.0 + dx, dy, dist = _wrap_delta(98.0, 20.0, 2.0, 20.0, W, H) # straddle right/left edge + @test dx == 4.0 && dy == 0.0 && dist == 4.0 # short way is +4, not -96 + dx2, dy2, _ = _wrap_delta(10.0, 5.0, 13.0, 9.0, W, H) # no wrap needed + @test dx2 == 3.0 && dy2 == 4.0 + _, dyv, _ = _wrap_delta(0.0, 39.0, 0.0, 1.0, W, H) # vertical wrap + @test dyv == 2.0 + dxt, _, _ = _wrap_delta(0.0, 0.0, 50.0, 0.0, W, H) # tie at size/2 ⇒ +50 + @test dxt == 50.0 +end + +@testset "aim_heading (visual space, up=0 clockwise)" begin + # ship at (40,12); cells ~2:1 so the row delta is doubled. + @test isapprox(aim_heading(40.0, 12.0, 40.0, 2.0), 0.0; atol=1e-9) # cursor above ⇒ up + @test isapprox(aim_heading(40.0, 12.0, 60.0, 12.0), pi/2; atol=1e-9) # right ⇒ +π/2 + @test isapprox(abs(aim_heading(40.0, 12.0, 40.0, 22.0)), pi; atol=1e-9) # below ⇒ ±π + @test isapprox(aim_heading(40.0, 12.0, 20.0, 12.0), -pi/2; atol=1e-9) # left ⇒ −π/2 +end +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: FAIL/ERROR — `UndefVarError: _wrap_delta` (and `aim_heading`). + +- [ ] **Step 3: Implement the helpers** — insert into `src/game.jl` after `_wrap(v, hi) = mod(v, hi)`: + +```julia +# Toroidal signed delta from (ax,ay) to (bx,by) on a width×height torus. Each axis +# takes the minimum-magnitude candidate over {d, d-size, d+size}; at the |d|==size/2 +# boundary the tie-break prefers the non-negative candidate so the normal is +# deterministic. Returns (dx, dy, dist=hypot(dx,dy)) — the VECTOR, so collision code +# derives normal/closing-speed/push-apart from one wrapped delta. +function _wrap_axis(d, size) + cands = (d, d - size, d + size) + best = cands[1] + for c in cands + if abs(c) < abs(best) || (abs(c) == abs(best) && c > best) + best = c + end + end + return best +end + +_wrap_delta(ax, ay, bx, by, width, height) = + (dx = _wrap_axis(bx - ax, width); dy = _wrap_axis(by - ay, height); (dx, dy, hypot(dx, dy))) + +# Visual-space heading from ship (sx,sy) toward cursor (cx,cy). Cells are ~2:1, so +# the row delta is multiplied by 2 (cell aspect) before atan, so the nose points at +# the cursor ON SCREEN. up=0, clockwise (matches dir(φ)=(sin φ, -cos φ)). +aim_heading(sx, sy, cx, cy) = atan(cx - sx, -((cy - sy) * 2.0)) +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: PASS — both new testsets green; all prior tests still pass. + +- [ ] **Step 5: Commit** + +```bash +git add examples/asteroid_tui/src/game.jl examples/asteroid_tui/test/test_game.jl +git commit -m "feat(#E): wrap-aware delta + visual-space aim_heading helpers" +``` + +--- + +## Task 2: The twin-stick `Input` contract switch (atomic) + +One commit: new `Input`, the stateful input layer, the strafe/aim movement, edge-triggered debug, spawn protection, and all consumers + tests updated together. The module is uncompilable between the `Input` change and the consumer updates, so do all edits, then run once. + +**Files:** +- Modify: `src/input.jl` (Input struct + docstring) +- Modify: `src/game.jl` (constants, `GameState`, `new_game`, `_advance_ship!`, `tick!` debug edge) +- Modify: `src/render_tachikoma.jl` (`InputState`, `sweep_stale!`, `fold_input`, `_poll_input!`, `run_game` wiring, header/docstrings) +- Create: `test/test_input.jl`; Modify: `test/runtests.jl` (include it) +- Modify: `test/test_game.jl`, `test/test_gameloop.jl`, `test/test_draw.jl` (field set + new asserts) + +- [ ] **Step 1: Write the failing/updated tests first.** + +Create `test/test_input.jl` (headless unit tests for the pure helpers — note `aim` is the **cursor tuple**, not a φ): + +```julia +# SPDX-License-Identifier: MIT +using AsteroidTUI: Input, sweep_stale!, fold_input, InputState, DECAY_WINDOW +using Test + +@testset "input helpers (headless, no terminal)" begin + @testset "sweep_stale! evicts only keys older than the window" begin + held = Dict{Tuple{Symbol,Char},Int}((:char,'w')=>10, (:char,'a')=>4, (:char,'d')=>6) + ret = sweep_stale!(held, 10, DECAY_WINDOW) # DECAY_WINDOW == 5 + @test ret === held + @test haskey(held, (:char,'w')) && haskey(held, (:char,'d')) + @test !haskey(held, (:char,'a')) # 10-4==6 > 5 ⇒ evicted + end + @testset "sweep_stale! boundary now-last==window is kept" begin + held = Dict{Tuple{Symbol,Char},Int}((:char,'w')=>5) + sweep_stale!(held, 10, 5); @test haskey(held, (:char,'w')) # 5 not > 5 + sweep_stale!(held, 11, 5); @test !haskey(held, (:char,'w')) # 6 > 5 + end + @testset "fold_input maps held keys to strafe + Space-fire + quit" begin + st = InputState(80, 24) + st.held[(:char,'w')]=0; st.held[(:char,'d')]=0; st.held[(:char,' ')]=0 + inp = fold_input(st, 0) + @test inp.up && inp.right && !inp.down && !inp.left + @test inp.fire # Space ⇒ fire + @test inp.aim === nothing # no cursor yet + st2 = InputState(80, 24); st2.held[(:left,'\0')]=0; st2.held[(:escape,'\0')]=0 + inp2 = fold_input(st2, 0); @test inp2.left && inp2.quit + end + @testset "fold_input fire from lmb_down" begin + st = InputState(80, 24); st.lmb_down = true + @test fold_input(st, 0).fire + end + @testset "fold_input emits the cursor as aim (raw, no φ math here)" begin + st = InputState(80, 24); st.cursor = (60, 2) + @test fold_input(st, 0).aim == (60.0, 2.0) # passed through; φ is computed in the sim + end +end +``` + +Register it in `test/runtests.jl` by adding `"test_input.jl"` **into the existing filename tuple** the file iterates (it does `for f in ("test_cellbuffer.jl", …, "test_golden.jl"); include(f); end` — not standalone `include()` calls). Put it first: + +```julia + for f in ("test_input.jl", "test_cellbuffer.jl", "test_cellbackend.jl", "test_prose.jl", + "test_pack.jl", "test_game.jl", "test_fracture.jl", "test_draw.jl", + "test_gameloop.jl", "test_golden.jl") + include(f) + end +``` + +Replace `test/test_game.jl`'s `tick! physics` testset (strafe + aim + edge-debug; keep the `respawn + invuln` testset; add `spawn protection`): + +```julia +@testset "tick! physics" begin + g = new_game(Xoshiro(42); width=120, height=40, n_asteroids=3) + for _ in 1:5; tick!(g, Input(up=true)); end # strafe imparts velocity, world wraps + @test (g.ship.vx, g.ship.vy) != (0.0, 0.0) + @test 0 <= g.ship.x <= g.width && 0 <= g.ship.y <= g.height + gs = new_game(Xoshiro(42); width=120, height=40, n_asteroids=3); φ0 = gs.ship.φ + tick!(gs, Input(left=true)) + @test gs.ship.vx != 0.0 && gs.ship.φ == φ0 # `left` STRAFES, does NOT turn + ga = new_game(Xoshiro(42); width=120, height=40, n_asteroids=3) + tick!(ga, Input(aim=(80.0, 5.0))) # aim is the cursor cell + @test ga.ship.φ == aim_heading(ga.ship.x, ga.ship.y, 80.0, 5.0) + φ1 = ga.ship.φ; tick!(ga, Input()); @test ga.ship.φ == φ1 # aim===nothing ⇒ φ held + g2 = new_game(Xoshiro(1)) + for _ in 1:20; tick!(g2, Input(fire=true)); end + @test g2.ship.charge == CHARGE_MAX + tick!(g2, Input(fire=false)); @test g2.beam.active && g2.ship.charge == 0 + g4 = new_game(Xoshiro(2)); @test !g4.debug + tick!(g4, Input(debug=true)); @test g4.debug # edge: one press toggles on + tick!(g4, Input(debug=true)); @test g4.debug # held ⇒ NOT re-toggled (no strobe) + tick!(g4, Input(debug=false)); tick!(g4, Input(debug=true)); @test !g4.debug # next press toggles +end + +@testset "spawn protection" begin + g = new_game(Xoshiro(8)) + @test g.ship.invuln > 0 && ship_visible(g) # fresh ship invulnerable AND visible at tick 0 +end +``` + +Update `test/test_gameloop.jl`: the scripted vocabulary (lines ~35-42), the quit poll (line ~82), the entities-evolve subtest (lines ~90-102), and the `step_frame!` subtest (line ~109): + +```julia + # (lines 35-42) scripted vocabulary — strafe + aim, no thrust: + script = Input[ + Input(left=true), Input(left=true), Input(up=true), Input(up=true), + Input(right=true), Input(fire=true), Input(fire=true), Input(fire=true), + Input(fire=false), Input(aim=(10.0, 3.0)), Input(debug=true), Input(), + Input(down=true), Input(right=true), Input(fire=true), Input(fire=false), + ] + # (line ~82) quit subtest: + poll = frame -> (frame == 10 ? Input(quit=true) : Input(up=true)), + # (lines ~90-102) entities-evolve: capture θ as a vector, harden the rotation assert: + θ0 = [a.θ for a in g.asteroids] + poll = frame -> Input(up=true, right=true), + @test (g.ship.x, g.ship.y) != (x0, y0) + @test any(((a, t),) -> a.θ != t, zip(g.asteroids, θ0)) || !isempty(g.shards) + # (line ~109) step_frame! subtest: + step_frame!(g, cb, Input(up=true)) +``` + +Update `test/test_draw.jl` line 8: `for _ in 1:10; tick!(g, Input(up=true)); end`. + +- [ ] **Step 2: Run to verify failure** (compile error first — expected, the consumers aren't updated yet) + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: FAIL — `Input` has no field `thrust` / `UndefVarError: sweep_stale!`/`InputState`. (Once consumers land in Step 3, this turns green.) + +- [ ] **Step 3a: Rewrite `src/input.jl`** (struct + docstring): + +```julia +# SPDX-License-Identifier: MIT +""" + Input(; up=false, down=false, left=false, right=false, fire=false, + aim=nothing, debug=false, quit=false) + +One tick's intent, decoupled from key encoding (twin-stick). + * `up`/`down`/`left`/`right` are **strafe** flags — direct-velocity movement; `left`/`right` + strafe, they do NOT turn. + * `fire` held grows the charge; the first `fire=false` after `fire=true` launches. + * `aim` is the **cursor cell** `(cx, cy)` or `nothing`. The sim turns it into a heading from the + live ship position; `nothing` ⇒ leave φ unchanged (headless paths stay reproducible). +All fields keyword-only. +""" +Base.@kwdef struct Input + up::Bool = false + down::Bool = false + left::Bool = false + right::Bool = false + fire::Bool = false + aim::Union{Nothing,Tuple{Float64,Float64}} = nothing + debug::Bool = false + quit::Bool = false +end +``` + +(Leave `ScriptedInput` and `next_input!` below it unchanged.) + +- [ ] **Step 3b: `src/game.jl` constants + `GameState` + `new_game`.** Replace the `THRUST`/`TURN` constants: + +```julia +const CHARGE_MAX = 5 +const INVULN_TICKS = 120 # ~2s; (120÷10)%2==0 ⇒ ship_visible at tick 0 +const MOVE_ACCEL = 0.18 # per-axis velocity added per held strafe key +const FRICTION = 0.90 # light friction; crisp strafe stop +const SHATTER_CLOSING = 0.9 # asteroid closing-speed: ≥ ⇒ fracture, < ⇒ bounce +``` + +Add `prev_debug` and `n_target` to `GameState` (between `prev_fire` and `respawn_in`, and after `last_hit_glyphs` respectively — append `n_target` last to minimize churn): + +```julia +mutable struct GameState + width::Int; height::Int + ship::Ship + asteroids::Vector{Asteroid} + shards::Vector{Shard} + beam::Beam + rng::Xoshiro + tick_count::Int + debug::Bool + prev_fire::Bool + prev_debug::Bool # edge-triggered debug toggle + respawn_in::Int + last_hit_glyphs::Vector{String} + n_target::Int # replenish target (starting n_asteroids) +end +``` + +`new_game` — spawn invuln, `prev_debug`, and `n_target`. (The spec's spawn-protection has two parts — initial invulnerability *and* keeping asteroids off the centre spawn cell; the `INVULN_TICKS = 120` (~2 s) initial invuln **subsumes** the centre-clearing, since `_resolve_ship_collision!` skips collision while `invuln > 0`, so the player cannot die on frame 1 even if an asteroid spawns on the centre. No separate centre-clear is implemented.) + +```julia +function new_game(rng::Xoshiro = Xoshiro(0); width=120, height=40, n_asteroids=5) + ship = Ship(width/2, height/2, 0.0, 0.0, 0.0, 0, true, INVULN_TICKS) + asteroids = [_spawn_asteroid(rng, width, height) for _ in 1:n_asteroids] + beam = Beam(false, 0.0, 0.0, 0.0, 0, 0) + return GameState(width, height, ship, asteroids, Shard[], beam, rng, 0, false, + false, false, 0, String[], n_asteroids) +end +``` + +- [ ] **Step 3c: `src/game.jl` `_advance_ship!`** — strafe + aim (decoupled from φ): + +```julia +function _advance_ship!(g::GameState, in::Input) + s = g.ship + s.alive || return + in.aim !== nothing && (s.φ = aim_heading(s.x, s.y, in.aim[1], in.aim[2])) + ax = (in.right ? 1.0 : 0.0) - (in.left ? 1.0 : 0.0) + ay = (in.down ? 1.0 : 0.0) - (in.up ? 1.0 : 0.0) + if ax != 0.0 || ay != 0.0 + inv = 1.0 / hypot(ax, ay) # normalise diagonals + s.vx += MOVE_ACCEL * ax * inv + s.vy += MOVE_ACCEL * ay * inv + end + s.vx *= FRICTION; s.vy *= FRICTION + s.x = _wrap(s.x + s.vx, g.width); s.y = _wrap(s.y + s.vy, g.height) + s.invuln > 0 && (s.invuln -= 1) +end +``` + +- [ ] **Step 3d: `src/game.jl` `tick!`** — edge-triggered debug (collision/replenish calls come in later tasks; keep the existing `_resolve_collisions!` call for now): + +```julia +function tick!(g::GameState, in::Input) + _handle_respawn!(g) + in.debug && !g.prev_debug && (g.debug = !g.debug) # toggle once per press + g.prev_debug = in.debug + _advance_ship!(g, in) + _advance_asteroids!(g) + _handle_charge_and_beam!(g, in) + _resolve_collisions!(g) + g.tick_count += 1 + return g +end +``` + +- [ ] **Step 3e: `src/render_tachikoma.jl`** — replace the old `_poll_input` (lines ~137-182) with `InputState` + pure helpers + `_poll_input!`. Note `InputState` has **no** `cx/cy` (aim is ship-relative, computed in the sim), and `fold_input` passes the cursor through: + +```julia +# --- input: stateful twin-stick over the real Tachikoma 2.1.0 API --------------- +# The PURE logic (sweep_stale!, fold_input) needs no terminal and is headless-tested; +# _poll_input! needs a live TK.poll_event and is the human tier-2/3 check. + +""" + InputState(width, height) + +Per-`run_game` mutable input state threaded across frames: `held` is +`(key,char) => last-seen frame`, `cursor` the last mouse cell (`nothing` until the +first event), `lmb_down` the edge-derived left-button state. +""" +mutable struct InputState + held::Dict{Tuple{Symbol,Char},Int} + cursor::Union{Nothing,Tuple{Int,Int}} + lmb_down::Bool +end +InputState(width::Integer, height::Integer) = + InputState(Dict{Tuple{Symbol,Char},Int}(), nothing, false) + +# Decay window in loop frames (~33ms each at sleep(1/30)). Must exceed the worst-case +# inter-repeat interval on the slowest non-kitty autorepeat; tuned at the terminal. +const DECAY_WINDOW = 5 + +"Evict held keys whose last-seen frame is older than `window` before `now`. Mutates & returns." +function sweep_stale!(held::Dict{Tuple{Symbol,Char},Int}, now::Int, window::Int) + for (id, last) in held + now - last > window && delete!(held, id) + end + return held +end + +_char_held(held, chars) = any(id -> id[1] === :char && id[2] in chars, keys(held)) + +""" + fold_input(state, now) -> Input + +Pure: fold the held-key map + cursor + fire into one `Input`. WASD/arrows → strafe; +`aim` is the raw cursor cell (the sim computes φ from the live ship position); +`fire = lmb_down || Space-held`. +""" +function fold_input(state::InputState, now::Int) + held = state.held + up = _char_held(held, ('w','W')) || haskey(held, (:up,'\0')) + down = _char_held(held, ('s','S')) || haskey(held, (:down,'\0')) + left = _char_held(held, ('a','A')) || haskey(held, (:left,'\0')) + right = _char_held(held, ('d','D')) || haskey(held, (:right,'\0')) + debug = _char_held(held, ('?',)) + quit = _char_held(held, ('q','Q')) || haskey(held, (:escape,'\0')) || haskey(held, (:ctrl_c,'\0')) + fire = state.lmb_down || haskey(held, (:char,' ')) + aim = state.cursor === nothing ? nothing : + (Float64(state.cursor[1]), Float64(state.cursor[2])) + return Input(up=up, down=down, left=left, right=right, fire=fire, aim=aim, + debug=debug, quit=quit) +end + +""" + _poll_input!(state, term, frame) -> Input + +Drain events into `state`, decay stale keys, fold to one `Input`. Stamp `frame` on +the held map for `key_press`/`key_repeat`, delete on `key_release`; update cursor on +`mouse_move`/`mouse_drag`; `lmb_down` edge-derived from `mouse_left` press/drag→true, +release→false. Needs live `TK.poll_event` (tier-2/3). +""" +function _poll_input!(state::InputState, term, frame::Int)::Input + while true + evt = TK.poll_event(0.0005) + evt === nothing && break + if evt isa TK.KeyEvent + id = (evt.key, evt.char) + if evt.action == TK.key_press || evt.action == TK.key_repeat + state.held[id] = frame + elseif evt.action == TK.key_release + delete!(state.held, id) + end + elseif evt isa TK.MouseEvent + (evt.action == TK.mouse_move || evt.action == TK.mouse_drag) && (state.cursor = (evt.x, evt.y)) + if evt.button == TK.mouse_left + evt.action in (TK.mouse_press, TK.mouse_drag) && (state.lmb_down = true) + evt.action == TK.mouse_release && (state.lmb_down = false) + end + end + end + sweep_stale!(state.held, frame, DECAY_WINDOW) + return fold_input(state, frame) +end +``` + +- [ ] **Step 3f: `src/render_tachikoma.jl` `run_game` interactive branch** — construct `InputState`, thread it, enable/restore `1003h`: + +```julia + if interactive + term = TK.Terminal(; size = (cols = width, rows = height)) + state = InputState(width, height) + TK.enter_tui!(term) # alt-screen + raw mode + start_input! + print(term.io, "\e[?1003h"); flush(term.io) # any-motion mouse: enter_tui! enables 1000h+1002h+1006h (button-motion + SGR), not 1003h bare-motion + try + _run_loop!(g, cb, term; max_frames = max_frames, + poll = frame -> _poll_input!(state, term, frame), + pace = frame -> sleep(1 / 30)) + finally + print(term.io, "\e[?1003l"); flush(term.io) # restore: MOUSE_OFF omits 1003l + TK.leave_tui!(term) + end +``` + +(Leave the headless `else` branch — `poll = frame -> Input()` — unchanged.) Also rewrite the stale prose: the file header (`:1-17`), the `run_game` key-map docstring (`:96-97`) and interactive bullet (`:84-86`), to describe twin-stick + held-key decay (no "turn & thrust", no "momentary"). And rewrite **`run.jl`'s player-facing controls banner** (`run.jl:3-5`) — currently `# Controls: arrows / WASD turn & thrust, space charges (release to fire), …`: + +```julia +# Controls: WASD strafe (the mouse aims the ship), LMB or space charges (release to fire), +# ? toggles the debug overlay, q / Esc / Ctrl-C quit. +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: PASS — `test_input.jl`, the rewritten `test_game.jl`/`test_gameloop.jl`/`test_draw.jl` green; **the golden testset is still GREEN** (no `tick!` or `_draw_ship!` change yet). Confirm exit 0 via `echo $?`. + +- [ ] **Step 5: Commit** + +```bash +git add examples/asteroid_tui/src/input.jl examples/asteroid_tui/src/game.jl \ + examples/asteroid_tui/src/render_tachikoma.jl examples/asteroid_tui/test/ +git commit -m "feat(#E): twin-stick Input — WASD strafe + mouse-aim cursor + stateful poll, edge debug" +``` + +--- + +## Task 3: Spawn-velocity rescale (golden-safe) + +Bump asteroid speeds so big hits can exceed `SHATTER_CLOSING`. **Rescale an existing `rand` draw — add/remove none** — so `_spawn_asteroid`'s polygon stream (drawn before the velocity rands) is untouched and the golden is unperturbed. + +**Files:** Modify `src/game.jl` (`_spawn_asteroid`). + +- [ ] **Step 1: Change only the vx/vy coefficient** `0.6 → 1.4`: + +```julia + (rand(rng) - 0.5) * 1.4, (rand(rng) - 0.5) * 1.4, +``` + +(Max per-axis speed 0.3→0.7; diagonal head-on closing up to ~1.98 > 0.9, so shatters are reachable; typical closing stays below, so most pairs bounce.) + +- [ ] **Step 2: Run — confirm the golden is STILL green** (proves the rescale didn't perturb polygons) + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: PASS including `golden showcase frame` (unchanged hash). If the golden fails here, a draw was added/removed by mistake — revert and rescale only the coefficient. + +- [ ] **Step 3: Commit** + +```bash +git add examples/asteroid_tui/src/game.jl +git commit -m "feat(#E): bump asteroid spawn velocity (rescale only; golden unperturbed)" +``` + +--- + +## Task 4: Fracture frame-conversion (`fracture_asteroid!`) + +Honour the `impact` argument by converting the cell-space contact offset into the polygon's unit frame before `voronoi_shatter`. Everything below the conversion (pairing, `@assert`, `deleteat!`, `subprep`, scatter loop) stays byte-identical. + +**Files:** Modify `src/game.jl` (`fracture_asteroid!`), `test/test_fracture.jl`. + +- [ ] **Step 1: Write the failing off-centre test** — append to `test/test_fracture.jl` (and update the existing centre-hit caller, Step 3): + +```julia +@testset "fracture: off-centre rim impact (frame conversion, no truncation)" begin + g = new_game(Xoshiro(11); n_asteroids=1) + a = g.asteroids[1] + original = words(a.prep) + nword = count(s -> s.kind === :word, a.prep.segments) + requested = 2 + (nword >= 6 ? 2 : 0) + impact = GB.Point2{Float64}(a.radius * 0.8, 0.0) # cell-space offset near the rim + fracture_asteroid!(g, 1, impact) + @test isempty(g.asteroids) + rebuilt = vcat((words(sh.prep) for sh in g.shards)...) + @test rebuilt == original # lossless + @test g.last_hit_glyphs == original + @test length(g.shards) == requested # seeds landed INSIDE ⇒ no truncation +end +``` + +- [ ] **Step 2: Run — note both tests already pass (the impact arg is currently IGNORED)** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: **PASS** — this is *not* a red step. The current `fracture_asteroid!` discards `impact` and hardcodes `(0,0)` into `voronoi_shatter` (`game.jl:167`), so seeds land at the centroid and the full shard count is produced regardless of the passed offset. The off-centre test is a **regression guard**: it must stay green after Step 3, and (crucially) would FAIL if `impact` were honoured *without* the frame conversion (raw cell-space ≈7 seeds escape the ~±1 polygon → `voronoi_shatter` collapses to ~1 shard → `length==requested` fails). You can confirm it bites: after Step 3, temporarily drop the `/ a.radius` divisor and re-run — the off-centre `length(g.shards)==requested` assert fails — then restore it. + +- [ ] **Step 3: Implement the conversion** — in `fracture_asteroid!` replace the `voronoi_shatter` call (`game.jl:167`) with the conversion + call, and update the docstring's frame contract: + +```julia + # Convert the CELL-space contact offset into the polygon's own (~±1) frame and + # clamp into the polygon bbox so voronoi_shatter's seeds land inside the parent. + xs = (p -> p[1]).(a.poly); ys = (p -> p[2]).(a.poly) + fx = clamp(impact[1] / a.radius, minimum(xs), maximum(xs)) + fy = clamp(impact[2] / a.radius, minimum(ys), maximum(ys)) + polys = voronoi_shatter(a.poly, GB.Point2{Float64}(fx, fy); n_shards = n_shards) +``` + +Also update the existing centre-hit caller `test_fracture.jl:16` — the real line is `impact = GB.Point2{Float64}(a.x, a.y)` — to `impact = GB.Point2{Float64}(0.0, 0.0)` (cell-space zero offset — a centre hit). (Both the old `(a.x,a.y)` and the new `(0.0,0.0)` resolve to the polygon centroid under the conversion — `a.x,a.y` would clamp far into the bbox corner — so use the offset form to honour the new contract.) + +- [ ] **Step 4: Run to verify pass** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: PASS — both `test_fracture.jl` testsets green (centre-hit lossless; off-centre lossless **and** `length(g.shards) == requested`). + +- [ ] **Step 5: Commit** + +```bash +git add examples/asteroid_tui/src/game.jl examples/asteroid_tui/test/test_fracture.jl +git commit -m "fix(#E): fracture_asteroid! honours impact via cell→polygon frame conversion" +``` + +--- + +## Task 5: Asteroid↔asteroid collisions (bounce / shatter) + +**Files:** Modify `src/game.jl` (add `_resolve_asteroid_collisions!`, wire into `tick!`), `test/test_game.jl`. + +- [ ] **Step 1: Write the failing tests** — append to `test/test_game.jl` (`_wrap_delta` is already in the top import from Task 1): + +```julia +@testset "asteroid bounce separates overlapping pair (below threshold)" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=2) + a, b = g.asteroids[1], g.asteroids[2] + a.x=50.0; a.y=20.0; a.vx=0.0; a.vy=0.0 + b.x=50.0+(a.radius+b.radius)*0.5; b.y=20.0; b.vx=-0.05; b.vy=0.0 # low closing speed + n0 = length(g.asteroids); _,_,d0 = _wrap_delta(a.x,a.y,b.x,b.y,g.width,g.height) + @test d0 < a.radius + b.radius + tick!(g, Input()) + @test length(g.asteroids) == n0 # bounce, not fracture + a2,b2 = g.asteroids[1], g.asteroids[2]; _,_,d1 = _wrap_delta(a2.x,a2.y,b2.x,b2.y,g.width,g.height) + @test d1 >= d0 # pushed apart + @test all(a -> 0 <= a.x <= g.width && 0 <= a.y <= g.height, g.asteroids) # re-wrap holds in-bounds +end + +@testset "high closing speed fractures both" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=2) + a, b = g.asteroids[1], g.asteroids[2] + a.x=60.0; a.y=20.0; a.vx=2.0; a.vy=0.0 + b.x=60.0+(a.radius+b.radius)*0.5; b.y=20.0; b.vx=-2.0; b.vy=0.0 # head-on, high closing + shards0 = length(g.shards) + tick!(g, Input()) + @test length(g.asteroids) == 0 && length(g.shards) > shards0 +end +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: FAIL — asteroids pass through (count unchanged where fracture expected; no push-apart). + +- [ ] **Step 3: Implement `_resolve_asteroid_collisions!`** — insert in `src/game.jl` before `_resolve_collisions!`: + +```julia +# Asteroid↔asteroid: bounce below the closing-speed threshold, fracture both above. +# Wrap-aware. Collect fracture pairs and apply them AFTER the sweep so deleteat! +# never invalidates a live index mid-iteration. +function _resolve_asteroid_collisions!(g::GameState) + n = length(g.asteroids) + n < 2 && return g + to_fracture = Tuple{Int,Float64,Float64}[] + fractured = falses(n) + for i in 1:(n-1) + fractured[i] && continue + a = g.asteroids[i] + for j in (i+1):n + fractured[j] && continue + b = g.asteroids[j] + dx, dy, dist = _wrap_delta(a.x, a.y, b.x, b.y, g.width, g.height) + rsum = a.radius + b.radius + (dist >= rsum || dist < 1e-9) && continue + nx, ny = dx/dist, dy/dist # contact normal a→b + rvx, rvy = b.vx - a.vx, b.vy - a.vy + closing = -(rvx*nx + rvy*ny) # >0 ⇒ approaching + if closing >= SHATTER_CLOSING + push!(to_fracture, (i, nx*a.radius, ny*a.radius)) # cell-space contact offsets + push!(to_fracture, (j, -nx*b.radius, -ny*b.radius)) + fractured[i] = true; fractured[j] = true + break + else + p = rvx*nx + rvy*ny # elastic, equal-mass reflection + a.vx += p*nx; a.vy += p*ny + b.vx -= p*nx; b.vy -= p*ny + overlap = (rsum - dist)/2 + 0.01 # push apart; re-wrap to stay in-bounds + a.x = _wrap(a.x - nx*overlap, g.width); a.y = _wrap(a.y - ny*overlap, g.height) + b.x = _wrap(b.x + nx*overlap, g.width); b.y = _wrap(b.y + ny*overlap, g.height) + end + end + end + for (idx, idx_dx, idx_dy) in sort(to_fracture; by = first, rev = true) + idx <= length(g.asteroids) || continue + fracture_asteroid!(g, idx, GB.Point2{Float64}(idx_dx, idx_dy)) + end + return g +end +``` + +Wire into `tick!` after `_resolve_collisions!(g)`: + +```julia + _resolve_collisions!(g) # beam → asteroid + _resolve_asteroid_collisions!(g) # asteroid ↔ asteroid +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: PASS — bounce keeps count + separates + in-bounds; high-closing fractures both. + +- [ ] **Step 5: Commit** + +```bash +git add examples/asteroid_tui/src/game.jl examples/asteroid_tui/test/test_game.jl +git commit -m "feat(#E): asteroid↔asteroid wrap-aware bounce / fracture-both-on-hard-hit" +``` + +--- + +## Task 6: Ship↔asteroid death + +**Files:** Modify `src/game.jl` (add `_resolve_ship_collision!`, wire into `tick!`), `test/test_game.jl`. + +- [ ] **Step 1: Write the failing tests** — append to `test/test_game.jl`: + +```julia +@testset "ship dies on asteroid contact" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=1) + g.ship.invuln = 0 # drop spawn protection for the test + a = g.asteroids[1]; a.vx=0.0; a.vy=0.0; a.x=g.ship.x; a.y=g.ship.y + tick!(g, Input()) + @test !g.ship.alive && length(g.asteroids) == 1 # death; asteroid NOT removed + for _ in 1:65; g.asteroids[1].x=0.0; g.asteroids[1].y=0.0; tick!(g, Input()); end + @test g.ship.alive && g.ship.invuln > 0 # respawned, invulnerable +end + +@testset "invulnerable ship survives contact" begin + g = new_game(Xoshiro(3); n_asteroids=1) # fresh ship is invuln (spawn protection) + a = g.asteroids[1]; a.vx=0.0; a.vy=0.0; a.x=g.ship.x; a.y=g.ship.y + tick!(g, Input()) + @test g.ship.alive +end +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: FAIL — ship stays alive on contact (no ship-collision resolver yet). + +- [ ] **Step 3: Implement `_resolve_ship_collision!`** — insert in `src/game.jl` before `_resolve_collisions!`: + +```julia +# Ship↔asteroid: alive && not invulnerable && wrap-aware distance within the +# asteroid's radius ⇒ kill_ship!. The asteroid is never mutated (it continues). +function _resolve_ship_collision!(g::GameState) + s = g.ship + (s.alive && s.invuln == 0) || return g + for a in g.asteroids + _, _, dist = _wrap_delta(s.x, s.y, a.x, a.y, g.width, g.height) + if dist <= a.radius + kill_ship!(g) + return g + end + end + return g +end +``` + +Wire into `tick!` after `_resolve_asteroid_collisions!(g)`: + +```julia + _resolve_ship_collision!(g) # ship ↔ asteroid (death) +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: PASS — death on contact + asteroid survives + respawn invulnerable; invulnerable ship survives. + +- [ ] **Step 5: Commit** + +```bash +git add examples/asteroid_tui/src/game.jl examples/asteroid_tui/test/test_game.jl +git commit -m "feat(#E): wire ship↔asteroid death (kill_ship! on wrap-aware contact)" +``` + +--- + +## Task 7: Field replenish + +**Files:** Modify `src/game.jl` (add `_replenish_field!`, wire into `tick!`), `test/test_game.jl`. + +- [ ] **Step 1: Write the failing test** — append to `test/test_game.jl`: + +```julia +@testset "field replenish restores count to N" begin + N = 4 + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=N) + g.ship.invuln = 1_000_000 # keep ship alive; don't perturb the test + empty!(g.asteroids) + tick!(g, Input()) + @test length(g.asteroids) == 1 # one spawned per tick + # _replenish_field! runs LAST in tick! (after _advance_asteroids!), so the just-spawned + # asteroid is still exactly on its edge this tick — assert it here, before it drifts. + a = g.asteroids[1] + @test a.x == 0.0 || a.x == g.width || a.y == 0.0 || a.y == g.height + for _ in 1:10; tick!(g, Input()); end + @test length(g.asteroids) == N # caps at N (g.n_target), never exceeds +end +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: FAIL — count stays 0 after draining (no replenish). + +- [ ] **Step 3: Implement `_replenish_field!`** — insert in `src/game.jl` before `tick!`, and wire it in: + +```julia +# When the live count drops below the target, spawn ONE asteroid at a screen edge +# from a FIXED number of g.rng draws (no rejection loop) so the RNG stream stays +# predictable. `g.n_target` is the starting n_asteroids (set in new_game). +function _replenish_field!(g::GameState) + length(g.asteroids) >= g.n_target && return g + a = _spawn_asteroid(g.rng, g.width, g.height) + edge = rand(g.rng, 1:4); t = rand(g.rng) # fixed 2 extra draws + if edge == 1; a.x = t*g.width; a.y = 0.0 + elseif edge == 2; a.x = t*g.width; a.y = g.height + elseif edge == 3; a.x = 0.0; a.y = t*g.height + else a.x = g.width; a.y = t*g.height + end + push!(g.asteroids, a) + return g +end +``` + +Wire into `tick!` after `_resolve_ship_collision!(g)` (before `g.tick_count += 1`): + +```julia + _replenish_field!(g) # top up toward g.n_target (one per tick) +``` + +- [ ] **Step 4: Run to verify pass** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: PASS — count climbs one-per-tick to N, caps at N, edge-spawned. + +- [ ] **Step 5: Commit** + +```bash +git add examples/asteroid_tui/src/game.jl examples/asteroid_tui/test/test_game.jl +git commit -m "feat(#E): field replenish — edge-spawn one asteroid per tick toward n_target" +``` + +--- + +## Task 8: Directional ship glyph + beam-from-nose + leader cut + +**Files:** Modify `src/draw.jl` (`_draw_ship!`, `draw!` body + docstring, comments), `src/game.jl` (`_handle_charge_and_beam!` beam origin), `test/test_draw.jl`. + +- [ ] **Step 1: Write the failing φ=0 nose test** — append to `test/test_draw.jl`'s `draw!` testset (the determinism asserts at lines 18-21 stay): + +```julia + g.debug = false + g.ship.alive = true; g.ship.invuln = 0; g.ship.charge = 0 + g.ship.x = 40.0; g.ship.y = 12.0 + sx = round(Int, g.ship.x); sy = round(Int, g.ship.y) + # φ=0 ⇒ nose-up; the OLD wings (╱╲) and plume (┃) must be GONE. + g.ship.φ = 0.0 + b0 = CellBuffer(g.height, g.width); draw!(b0, g) + @test b0.chars[sy, sx] == '▮' # hull at centre + @test b0.chars[sy - 1, sx] == '▲' # nose one cell up + @test b0.chars[sy, sx - 1] != '╱' && b0.chars[sy, sx + 1] != '╲' # wings removed + @test b0.chars[sy + 1, sx] != '┃' # downward plume removed + # φ=π/2 ⇒ facing RIGHT ⇒ nose '▶' one cell to the right (catches a CCW octant table) + g.ship.φ = π/2 + b1 = CellBuffer(g.height, g.width); draw!(b1, g) + @test b1.chars[sy, sx + 1] == '▶' +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: FAIL — the current `_draw_ship!` draws swept wings `╱`/`╲` at `(sy,sx∓1)` and a `┃` plume at `(sy+1,sx)` (so the "removed" asserts fail) and never rotates the glyph (so the `φ=π/2 ⇒ '▶'` assert fails). The φ=0 nose `▲`/hull `▮` asserts already pass against the old glyph — that's expected; the wing/plume/octant asserts are the real red. + +- [ ] **Step 3: Rewrite `_draw_ship!`** in `src/draw.jl` (replace lines ~165-181): + +```julia +# Ship: a cyan hull at (sx,sy) with a directional NOSE one cell along heading φ +# (8-way octant table), and — while charging — the charge glyph one cell BEYOND the +# nose along φ. Pure function of g (no RNG/clock): same state ⇒ same cells. At φ=0 +# the nose is '▲' (nose-up), matching the golden's pinned pose. +# φ increases CLOCKWISE from up, so the table must run CW: k·45° = N,NE,E,SE,S,SW,W,NW. +# (A CCW table makes the nose point backwards — e.g. '◀' while facing right.) +const SHIP_OCTANT = ('▲', '◥', '▶', '◢', '▼', '◣', '◀', '◤') # N NE E SE S SW W NW + +function _draw_ship!(buf::CellBuffer, g) + ship_visible(g) || return buf + s = g.ship + sx = round(Int, s.x); sy = round(Int, s.y) + dx, dy = sin(s.φ), -cos(s.φ) + nose = SHIP_OCTANT[mod(round(Int, s.φ / (π/4)), 8) + 1] # mod handles negative φ + put_char!(buf, sy, sx, '▮'; fg = COL_SHIP, bold = true) # hull + put_char!(buf, round(Int, s.y + dy), round(Int, s.x + dx), nose; fg = COL_SHIP, bold = true) # nose + if s.charge > 0 + put_char!(buf, round(Int, s.y + 2dy), round(Int, s.x + 2dx), + CHARGE_GLYPH[s.charge + 1]; fg = COL_BEAM, bold = true) + end + return buf +end +``` + +- [ ] **Step 4: Cut the targeting leader + clean comments + beam origin.** + +In `draw!`'s body, delete the `# targeting leader:` comment and the whole `if ship_visible(g) && !isempty(g.asteroids) … _draw_leader! … end` block (lines ~215-223). Update the `draw!` docstring (drop "targeting leader" from the layer order). Update comments: `COL_BEAM` (line 8) `— beam + charge indicator`; the decoration-helpers comment (lines ~47-51) drop "leader"/"thrust plume". + +In `src/game.jl` `_handle_charge_and_beam!` (line ~88), set the beam origin to the nose: + +```julia + g.beam = Beam(true, s.x + sin(s.φ), s.y - cos(s.φ), s.φ, 4 + 6 * s.charge, 6) +``` + +- [ ] **Step 5: Run to verify pass** (golden will now be STALE — that's expected; Task 9 regenerates it) + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: `test_draw.jl` PASS (φ=0 nose `▲`, hull `▮`, wings/plume gone, φ=π/2 nose `▶`, determinism holds); **`golden showcase frame` FAILS the hash compare** (the draw.jl visual changes — glyph, plume, leader — altered the rendered frame). Note it — do NOT regenerate yet; commit the code, regenerate in Task 9. + +- [ ] **Step 6: Commit** + +```bash +git add examples/asteroid_tui/src/draw.jl examples/asteroid_tui/src/game.jl examples/asteroid_tui/test/test_draw.jl +git commit -m "feat(#E): directional 8-way ship glyph, beam-from-nose, cut targeting leader" +``` + +--- + +## Task 9: Regenerate + visually verify the golden + +The golden is a static `draw!` (no tick loop), so it is invariant to every `tick!` change; it changes here only from the **Task 8 `draw.jl` visual edits**: the 8-way ship glyph, the removed wings/plume, and the cut targeting-leader (the leader dotted into the golden frame because `_run_golden` has a visible ship + 2 asteroids). A larger-than-just-the-ship diff is expected and correct — no `tick!` change has leaked in. + +**Files:** `test/golden/frame60.{sha256,txt}`, `test/test_golden.jl` (comment). + +- [ ] **Step 1: Regenerate both golden files** + +Run: `UPDATE_GOLDEN=1 julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' 2>&1 | tee "test-logs/${CLAUDE_CODE_SESSION_ID:-local}.log"` +Expected: writes `frame60.sha256` + `frame60.txt`, re-reads, asserts in the same run → green. + +- [ ] **Step 2: VISUALLY INSPECT the rendered frame** (a re-hash is NOT sign-off — project rule) + +Open `examples/asteroid_tui/test/golden/frame60.txt` and confirm: (a) the φ=0 ship is a sensible **nose-up** form (`▲` above `▮`); (b) the downward thrust plume (`┃`/`┋`) is **gone**; (c) the dotted auto-targeting leader is **gone**; (d) the two text-mass blobs + stat callouts are still coherent. If anything looks wrong, fix `_draw_ship!`/`draw!` and re-regenerate. + +- [ ] **Step 3: Update the stale comment** in `test/test_golden.jl` (block lines ~21-31): drop "with thrust plume"; add that this is a static `draw!` that regenerates only on a `_draw_ship!` change. + +- [ ] **Step 4: Re-run WITHOUT the env var and independently confirm exit 0** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()'; echo "EXIT=$?"` +Expected: full suite green, `EXIT=0` (check the code directly, not a piped tail). + +- [ ] **Step 5: Commit** (both golden files + comment; never the Manifest) + +```bash +git status --short # confirm Manifest.toml is NOT listed +git add examples/asteroid_tui/test/golden/frame60.sha256 \ + examples/asteroid_tui/test/golden/frame60.txt \ + examples/asteroid_tui/test/test_golden.jl +git commit -m "test(#E): regenerate golden for directional ship glyph (visually verified)" +``` + +--- + +## Task 10: Final verification + human tier-2/3 handoff + +- [ ] **Step 1: Full suite, independently verified** + +Run: `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()'; echo "EXIT=$?"` +Expected: all testsets pass, `EXIT=0`. + +- [ ] **Step 2: Record the human tier-2/3 checklist** (cannot be unit-tested; verify at a real terminal via `julia --project=examples/asteroid_tui run.jl`, on at least one kitty-class and one non-kitty terminal): + - WASD feels responsive (crisp on kitty; acceptable past the first-press hitch elsewhere); ship glyph rotates to face the cursor. + - LMB **and** Space both charge/fire; beam leaves the nose along the cursor direction. + - Asteroids bounce; hard head-ons occasionally shatter into prose shards. + - Ship dies on contact, respawns with the ~3 Hz invuln blink; field stays ~N. + - `?` toggles the debug overlay once per press (no strobe); `q`/`Esc`/`Ctrl-C` quit; terminal is restored on exit. + +- [ ] **Step 3:** This plan does **not** invoke finishing-a-development-branch (the orchestrator integrates / opens the PR). Leave the branch ready. + +--- + +## Tuning knobs (magnitudes only — set at a live terminal; none change structure) + +- `MOVE_ACCEL` (0.18) / `FRICTION` (0.90) — movement feel. +- `DECAY_WINDOW` (5 frames) — must exceed the worst-case inter-repeat interval (frames) on the slowest non-kitty autorepeat, else held keys stutter. +- `_spawn_asteroid` velocity coefficient (1.4) + `SHATTER_CLOSING` (0.9) — bounce-vs-shatter mix. +- Replenish cadence (one/tick) and `SHIP_OCTANT` glyphs. + +## Notes for the implementer +- Asteroid-rotation coverage lives in `test_gameloop.jl`'s hardened "entities evolve" assert (`Xoshiro(42)`, n=3 — verified to have nonzero ω). The old fragile `Xoshiro(5)` index-1 rotation assert in `tick! physics` is removed by the Task 2 rewrite; don't reintroduce it. +- `a.radius > 0` always (`_spawn_asteroid`: `6.0 + 6.0*rand ≥ 6.0`), so `impact / a.radius` needs no divide-by-zero guard. +- Tachikoma's `poll_event`/`enter_tui!`/`leave_tui!` are unexported internals (`TK.`-qualified); the `[compat] Tachikoma = "2.1.0"` pin guards this coupling. diff --git a/docs/superpowers/specs/2026-05-29-E-asteroid-tui-interactive-polish-design.md b/docs/superpowers/specs/2026-05-29-E-asteroid-tui-interactive-polish-design.md new file mode 100644 index 0000000..d6cf0b5 --- /dev/null +++ b/docs/superpowers/specs/2026-05-29-E-asteroid-tui-interactive-polish-design.md @@ -0,0 +1,470 @@ +# Asteroid TUI — interactive controls & collision polish — design + +**Date:** 2026-05-29 +**PR:** #26 — `[WIP] #E — Asteroid TUI demo (interactive polish in progress)` +**Branch:** `demos-E-asteroid-tui` +**Status:** design-converged (brainstorming dialogue 2026-05-29), ready for implementation plan + +## Problem + +PR #26's game *engine* (the `measure-once-layout-many` showcase: shape-packed prose, +Voronoi fracture into re-packed prose slices, pure `tick!`/`draw!` over a renderer-agnostic +`CellBuffer`, headless golden + smoke tests) works and is well-structured. The **interactive +gameplay layer** — explicitly labelled "tier-2/3, not unit-testable" in the PR and never +validated at a live terminal — is roughly half-finished. Two player-facing things are broken, +plus a cluster of latent bugs that surface the moment the demo is actually played: + +1. **Controls don't work.** `_poll_input` treats turn/thrust as *momentary* (true only on the + frame a keypress byte arrives) and discards every key-release event. Holding a key therefore + relies on OS autorepeat — one press, a ~500 ms gap, then stuttery repeats. The ship glyph is + a static nose-up `▲` that never reflects heading `φ`, so even a successful turn gives no + visual feedback and the beam fires along an invisible direction. + +2. **Collision is broken.** `_resolve_collisions!` handles **only** beam→asteroid. + - **Ship↔asteroid collision does not exist.** `kill_ship!` is defined and exported but + called *only* from a test — never from the game loop. The whole death / respawn / + invulnerability / blink machinery is dead code in play; the ship flies through asteroids. + - **Asteroid↔asteroid collision does not exist.** Asteroids drift on independent velocities + and pass through each other. `draw!`'s "non-overlap by z-order + scene composition" claim + holds only for the seed-pinned golden frame; in live play asteroid prose blobs overlap into + an illegible pile. + +Latent bugs found in the same sweep (fixed as part of this work): +- **Ignored impact point.** `_resolve_collisions!` computes the beam's impact offset and passes + it to `fracture_asteroid!`, which then hardcodes `Point2(0.0, 0.0)` into `voronoi_shatter` — + fractures always seed at the centroid, never where the hit landed. +- **No toroidal-wrap awareness.** Beam/asteroid distance uses raw `hypot`, blind to the screen + wrap, so bodies straddling opposite edges never register. Any new collision check inherits + this unless we add wrap-aware distance. +- **No spawn protection.** `new_game` starts the ship with `invuln = 0` dead-centre where + asteroids can spawn; harmless today, instant death once ship collision is live. +- **Debug toggle not edge-triggered.** `g.debug = !g.debug` flips every frame the `?` key + autorepeats, strobing the overlay instead of toggling it. +- **Field drains permanently.** Nothing replenishes asteroids; once all are shattered and shards + TTL out the screen goes empty — a looping showcase needs replenish. + +## Goal + +Make the interactive demo genuinely playable at a real terminal — responsive twin-stick +controls, working collisions, a ship that can die and respawn, and a field that keeps the +showcase looping. The pure `tick!`/`draw!` split, the renderer-agnostic `CellBuffer`, and the +*engine* showcase (shape-pack → fracture → re-pack) are preserved; the headless tests keep +covering the loop end-to-end, though individual assertions are rewritten where they asserted the +wrong thing (see Refactoring posture). + +## Refactoring posture + +This demo was originally built **test-first-to-pass rather than to work**, then patched just enough +to boot. So the existing code and tests are **not** a faithful spec of intended behavior, and this +work has explicit operator latitude to **refactor things that don't make sense and do them +properly** rather than minimally patch around them. Concretely: + +- A test that asserts a test-gaming artifact (e.g. `left` *turns* the ship; `draw!` non-overlap "by + seed-pinned z-order composition") is **rewritten to assert real gameplay**, not preserved. +- Structures that exist only to pass a test or dodge a crash (the momentary-input model, the + `_poll_input`/`_present` stubs) are replaced outright. +- The bar for removing something is: **verify it isn't load-bearing first** (grep call sites, read + the test), then refactor — not reverence, but not recklessness either. + +## Out of scope + +Score/lives UI, sound, menus, multiple weapon types, shard re-fracture, difficulty curves. +This is interactive *polish* of an existing demo, not a ground-up rewrite. + +## Control model — twin-stick + +Movement and facing are **decoupled** (twin-stick): **WASD strafes**, **the mouse aims**. + +### Movement (WASD → direct velocity) + +W/A/S/D map to up/left/down/right **direct-velocity** movement (not thrust-along-heading). +Diagonals are normalised so combined keys don't move faster. Light friction smooths starts/stops +and bridges short input gaps. No heading coupling — A/D strafe, they do not turn. + +The terminal key-hold problem (the root of "controls don't work") is solved with **one unified +mechanism**, not a per-terminal branch: + +- A **held-keys map** where each held key carries a *last-seen tick*. +- The tick is stamped on **`key_press` AND `key_repeat`** (both autorepeat and the kitty protocol + report a held key's continuations as `key_repeat`, never a fresh `key_press` — so stamping only + on press would let the decay window evict a key mid-hold; the repeat refresh is essential). +- A key is evicted when **either** a `key_release` event arrives **or** it hasn't been refreshed + within a short **decay window** (~4–6 ticks; tuned at the terminal). + +Tachikoma **probes** for the kitty keyboard protocol in `enter_tui!` (`_detect_kitty_keyboard!`, +`terminal.jl:1302`) and enables `KITTY_FLAGS = 11` (which includes "report event types"; verified +`terminal.jl:16-21`) **only if the terminal answers the `CSI ? u` query**. On terminals that answer +(kitty, ghostty, WezTerm, foot, recent Konsole/Alacritty) `poll_event` then delivers real +**press / repeat / release** (raw `poll_event` has no action gating — `handle_all_key_actions` lives +only in the App/Model framework, which this demo does not use) — the release event evicts instantly +→ crisp hold-to-move, +instant stop; the decay timeout rarely fires. On terminals without the protocol (gnome-terminal/VTE, +xterm, macOS Terminal, most tmux) no release ever arrives, so the decay timeout does the eviction; +autorepeat (reclassified to `key_repeat`) refreshes the timestamp while held. **Same code path +either way** — the release event is just an optional early-exit; `t.kitty_keyboard` is *not* +branched on in the movement logic. + +Tachikoma already maintains a module-global `_KEYS_DOWN` set (`events.jl:46-66`) that reclassifies +a repeated press to `key_repeat`; our held-key map sits **on top** of it. We still need our own map +because `_KEYS_DOWN` never times out (it clears only on a real release or `reset_key_state!`), so on +non-kitty terminals only the decay sweep can drop a key — we are not duplicating release tracking we +could get for free. + +**Accepted limitation:** on non-kitty terminals the *OS-level* ~500 ms initial-autorepeat delay +(a keyboard/terminal setting, not a Tachikoma behavior) means the *first* press-then-hold has an +inherent hitch before continuous motion begins. No code can manufacture events the terminal +withheld; the decay window is tuned to keep everything *after* that first hitch smooth. + +### Aim (mouse → heading `φ`) + +The mouse cursor sets the ship's heading `φ` every frame (and thus the beam direction). +`enter_tui!` enables `MOUSE_ON = 1000h + 1002h + 1006h` (`terminal.jl:13`); `1002h` reports motion +only *while a button is held*, and there is no `1003h` (any-motion). So to get **bare cursor +motion** we print `\e[?1003h` to `term.io` after `enter_tui!` and `\e[?1003l` before `leave_tui!` +(`leave_tui!`'s `MOUSE_OFF` does not include `1003l`, so we must restore it ourselves). SGR +decoding (`1006h`) is already on and `1003h` bare-motion reports decode through it as a +`MouseEvent` with `button = mouse_none, action = mouse_move` (verified `events.jl:332-334`). + +> Tachikoma's public `toggle_mouse!` is **not** usable here: it flips the whole `1000/1002/1006` +> set and has no `1003` control. And because the `Terminal` constructor sets `mouse_enabled = true` +> (`terminal.jl:77`) while `enter_tui!` only *prints* `MOUSE_ON` without updating that flag, the +> first `toggle_mouse!` would flip `true → false` and emit `MOUSE_OFF`, *disabling* tracking. Raw +> `1003h`/`1003l` to `term.io` is the deliberate, only mechanism in Tachikoma 2.1.0. + +**Aim is ship-relative, computed in the sim — not the input layer.** The ship strafes around a +fixed viewport (WASD moves it; no camera), so the heading must be measured from the **live ship +position** to the cursor, not from screen-centre. Therefore: +- `Input.aim` carries the **cursor cell** `(cx, cy)` (or `nothing`), *not* a pre-computed `φ`. The + input layer just reports where the pointer is. +- `_advance_ship!` computes `φ = aim_heading(s.x, s.y, cx, cy)` when `in.aim !== nothing`, via a + pure helper `aim_heading(sx, sy, cx, cy) = atan(cx - sx, -((cy - sy) * 2.0))`. + +`φ` is in **visual space**: cells are ~2:1, so **cell aspect = height/width ≈ 2** multiplies the row +delta (the `* 2.0` above) so the nose points at the cursor *on screen*, using the existing +convention (up = 0, clockwise, matching `vy -= cos φ` / `dirx = sin φ`; verified at all four +cardinals — multiply, not divide). The same `φ` drives facing, the beam, and the rotated ship glyph; +the only side effect (slightly faster geometric turn per visual degree vertically) is imperceptible. +Keeping `aim_heading` in the sim makes it directly headless-testable through `tick!(g, Input(aim=…))`. + +**Aim persists between mouse moves.** With `1003h`, mouse events arrive only *when the cursor +moves*; idle frames carry none. So the input layer remembers the **last-known cursor position** and +re-emits it as `Input.aim` every frame, holding facing steady until the next move. This makes +`_poll_input!` stateful (see Renderer wiring). + +### Fire (LMB **or** Space) + +`fire = lmb_down || (Space present in the held-key map this frame)`. **Space is just another entry +in the unified held-key map** (set on press/repeat, evicted on release or decay-timeout); `lmb_down` +is the edge-derived mouse flag. Both feed the **same** charge state; the existing +hold-to-charge / release-to-launch edge (`prev_fire`) is preserved, and the beam launches along the +current mouse-aimed `φ`. + +One consequence to accept: because Space rides the same decay eviction as movement, on a **non-kitty** +terminal (no release events) a Space *tap* stays "held" for the decay window (~4–6 ticks) before the +launch edge fires — so a tap yields charge ≈ the decay window (capped at `CHARGE_MAX = 5`) and +auto-launches, rather than a crisp charge-1 shot. On a **kitty** terminal the real release fires the +launch edge immediately, giving crisp tap-to-fire. This is the same press/release-vs-decay tradeoff +as movement, deliberately kept on one mechanism rather than special-casing Space; LMB (which always +has reliable press/release events) gives crisp firing on every terminal as the alternative. + +### Ship glyph reflects heading + +`_draw_ship!` is **rewritten** so facing is always visible (today's static nose-up `▲` never reads +`φ`). Design: + +- **One directional nose glyph per 45° octant**, chosen from a fixed 8-entry table indexed by + `round(φ / (π/4)) mod 8` — starter set `('▲','◤','◀','◣','▼','◢','▶','◥')` for + N/NW/W/SW/S/SE/E/NE (exact glyphs tunable by eye; **acceptance: at `φ = 0` it renders a sensible + nose-up form**, since the golden pins `φ = 0`). +- The nose is drawn **one cell from the ship centre along `φ`** (`round(sx + sin φ)`, + `round(sy − cos φ)`); the hull stays at `(sx, sy)`. The **charge indicator** moves to the cell + *beyond* the nose along `φ` (not the fixed `sy−2` it sits at today, which is wrong once rotated). +- The downward thrust plume is **removed** — meaningless under decoupled strafe movement. +- `_draw_ship!` must stay a **pure deterministic function of `g`** (no RNG, no frame counter beyond + what's already in `g`) so `test_draw.jl:18-21`'s determinism assertion holds. + +**Two aim-coupled `draw!` decisions:** +- **Beam origin moves to the nose.** Today the beam launches from the ship *centre* + (`Beam(true, s.x, s.y, …)`, `game.jl:88`); with a rotated multi-cell ship its first cells would + paint over the hull. The fix is **at `Beam(...)` construction in `_handle_charge_and_beam!` + (`game.jl:88`)** — pass the nose `(s.x + sin φ, s.y − cos φ)` as the origin — **not** in + `_draw_beam!` (which only *reads* `g.beam.x/y`). `_draw_beam!` then renders unchanged from the + new origin. +- **Cut the auto-targeting leader.** The ship→nearest-asteroid dotted leader (`draw.jl:215-223`) is + redundant under twin-stick — the mouse *is* the targeting affordance — and fights the new aim + read. Remove it (one fewer empty-cells-only layer). + +## Collision model + +All collision geometry is **wrap-aware** via a single helper that returns the **toroidal signed +delta vector** `(dx, dy)` — each component chosen as the minimum-magnitude of `{d, d−size, d+size}` +on its axis — with `distance = hypot(dx, dy)`. Returning the *vector* (not just a scalar distance) +is required: the bounce needs the contact **normal** (the unit wrapped delta), the **closing speed** +(relative velocity projected onto that normal), and the **push-apart** direction — all derived from +the same wrapped delta, so an edge-straddling pair separates the short way around the torus, not +across the whole field. A deterministic tie-break for the `|d| == size/2` boundary (e.g. prefer the +non-negative candidate) keeps the normal well-defined. Reused by beam→asteroid, asteroid↔asteroid, +and ship↔asteroid. + +### Asteroid ↔ asteroid — "bounce, but big hits shatter" + +On overlap (wrapped `distance` < sum of radii): +- **Below** a **closing-speed** threshold (closing speed = relative velocity · contact normal, not + raw `‖relvel‖`): **elastic bounce** — reflect relative velocity along the contact normal **and** + apply a positional push-apart along it so the pair separates and never sticks or overlaps into an + illegible blob. The push-apart **re-applies `_wrap`** to both bodies (or runs before + `_advance_asteroids!`'s wrap step) so the "all asteroids in `[0,W]×[0,H]`" invariant + (`test_gameloop.jl:61`) still holds after a collision mutates position. +- **At/above** the threshold: **fracture both** asteroids via the existing `fracture_asteroid!`, + seeded at the **actual contact point**. This fixes the ignored-impact-point bug — but honouring + the `impact` argument is **not** a no-transform passthrough: `voronoi_shatter` places its seeds + within `min(w,h)/4` of `impact` **in the polygon's own (unit-ish) frame** (`Silhouettes.jl:158-159`), + whereas the contact point is in **cell space** (magnitude up to `a.radius` ≈ 6–12). So + `fracture_asteroid!` must **convert the impact into the polygon frame** — divide the cell-space + contact offset by `a.radius` and clamp it into the polygon bbox — before passing it to + `voronoi_shatter`. (Today's hardcoded `(0,0)` "works" only because it happens to sit inside the + polygon.) + + **Precise contract for `fracture_asteroid!(g, idx, impact)`:** `impact` is a **cell-space contact + offset relative to the asteroid centre** (`px − a.x, py − a.y`; magnitude ≤ `a.radius`). The + function converts it to the polygon's unit frame (`impact ./ a.radius`, clamped into the polygon + bbox) before `voronoi_shatter`. The existing `test_fracture.jl:16` passes `GB.Point2(a.x, a.y)` — + the asteroid's *absolute* position — which only "passes" today because the argument is ignored; + under the contract that is a wrong-frame input and must change to a small offset (e.g. + `Point2(0.0, 0.0)` for a centre hit), still asserting lossless glyph preservation. + +Asteroid spawn velocities (`_spawn_asteroid`) are **bumped** so relative closing speeds can +actually exceed the shatter threshold (today's per-axis ±0.3 gives a max closing speed of only ≈ +0.85 cells/tick on a diagonal head-on, and typical closing speeds are far lower). **The bump must +be a rescale of the existing `rand` draws — not added/removed draws — so the RNG draw order in +`_spawn_asteroid` is unchanged** (see "Determinism & the golden frame": the golden's polygons come +from that RNG stream, and a draw-order change would perturb them). Exact spawn-velocity range and +threshold value are **tuned empirically at the terminal**; the design fixes the *mechanism*, not +the magic numbers. + +Shards remain non-colliding and TTL out (unchanged — out of scope to re-fracture). + +### Ship ↔ asteroid — death / respawn + +When the ship is alive and **not** invulnerable and its (wrap-aware) distance to any asteroid is +within that asteroid's radius, call `kill_ship!`. This activates the **existing** dormant +machinery: `_handle_respawn!` respawns after the timer with `INVULN_TICKS` of invulnerability, +and `ship_visible` blinks the ship at ~3 Hz while invulnerable. Two corollaries make it not feel +broken: +- **Spawn protection:** the ship gets initial invulnerability at `new_game` (not just on respawn), + and asteroids are kept clear of / pushed off the centre spawn cell so the player can't die on + frame 1. Two consequences: (a) `ship_visible` blinks while `invuln > 0`, so a fresh game now opens + with the ship blinking (cosmetic, intended); (b) the initial `invuln` must keep + `ship_visible(g) == true` at tick 0 (with `INVULN_TICKS = 120`, `(120÷10)%2 == 0` → visible, so + the golden's ship still renders) — or `_run_golden` zeroes `ship.invuln` after `new_game`. Verify + whichever against the regenerated golden. +- Death never mutates the colliding asteroid (it continues); only the ship reacts. + +### Field replenish + +When the live asteroid count drops below the target `N` (the `n_asteroids` the game started with), +**respawn one asteroid at a screen edge** (off-centre, away from the ship) so the showcase keeps +looping. Uses `g.rng` only, and derives the edge position from a **fixed number of `g.rng` draws** +(no rejection-sampling loop), so the post-replenish RNG stream stays predictable if a future +headless test ever pins it. + +## Input struct & determinism + +`Input` (`input.jl`) is extended to express the twin-stick scheme while keeping the **headless +scripted path deterministic**. The exact target struct: + +```julia +Base.@kwdef struct Input + up::Bool = false + down::Bool = false + left::Bool = false # strafe (NOT turn — turning is gone) + right::Bool = false # strafe + fire::Bool = false + aim::Union{Nothing,Tuple{Float64,Float64}} = nothing # cursor cell (cx,cy); nothing ⇒ leave φ unchanged + debug::Bool = false + quit::Bool = false +end +``` + +- The old `thrust` field is removed; `up`/`down` are added; `left`/`right` are retained as + identifiers but their **meaning changes from turn to strafe**. +- `aim` is the **cursor cell** `(cx, cy)`; `_advance_ship!` turns it into `φ` via `aim_heading` + (see Aim). When `aim === nothing`, `tick!` leaves `φ` unchanged — so the headless smoke path + (`Input()` each frame, plus the scripted-`Input` tests) never depends on a mouse and its `tick!` + arithmetic stays reproducible. (The *golden* needs no such guarantee — it runs no `tick!` and + consumes no `Input`; its determinism comes from the static `draw!`, see "Determinism & the golden + frame". All call sites use kwargs, so field order is not load-bearing.) +- `prev_debug` edge state for the debug toggle lives in **`GameState`** (`game.jl`, alongside the + existing `prev_fire`), **not** in `entities.jl`. `debug` becomes **edge-triggered** in `tick!` + (toggles once per press), fixing the strobe. + +`ScriptedInput` is structurally unchanged (still a `Vector{Input}`) and the headless `run_game` +path (`poll = Input()`) is unaffected by the mouse field. Test updates for the renamed/repurposed +fields: +- **`test_gameloop.jl`** scripts and **`test_game.jl`** / **`test_draw.jl`** all construct + `Input(thrust=…)` / `Input(left=…)` etc. — every such call is updated to the new field set. +- **A behavioral assertion changes, not just a name:** `test_game.jl:9` uses the removed + `Input(thrust=true)` (renamed to the strafe set); `test_game.jl:13` does + `tick!(g, Input(left=true)); @test g.ship.φ != φ0` — asserting `left` *turns*. Under strafe, + `left` no longer changes `φ`, so line 13 is **replaced** with a strafe-velocity assertion + (`left` changes `ship.vx`) plus a **new** assertion that `Input(aim=θ)` sets `ship.φ`. + `test_game.jl:25`'s single-press debug assertion still passes under edge-triggering (one press + toggles to `true`), but any *held*-debug test must account for `prev_debug`. Other tests' *intent* + (loop runs, buffer valid every frame, entities evolve, in-bounds invariants, charge/respawn) is + preserved. +- New helpers (`InputState`, `sweep_stale!`, etc.) stay **unexported**; tests reach them by name via + `using AsteroidTUI: sweep_stale!` — exactly as the existing tests already import the unexported + `fracture_asteroid!` / `_word_boundary_splits` (`test_fracture.jl:3`). Reserve the export list for + genuinely public API. + +## Renderer wiring (`render_tachikoma.jl`) + +Both the held-keys map and the last-known cursor position must survive across frames, so the +stateless `_poll_input(term) -> Input` becomes **stateful**: a small mutable `InputState` struct +is created once per `run_game` and threaded through the poll callback (`poll = frame -> +_poll_input!(state, term, frame)`). The headless path keeps `poll = frame -> Input()` and never +constructs an `InputState`. + +**Contract (pin these so the units aren't guessed):** +- `InputState.held :: Dict{Tuple{Symbol,Char},Int}` — keyed like Tachikoma's own `_KEYS_DOWN` + (`(evt.key, evt.char)`), value = last-seen **frame index**. +- `InputState.cursor :: Union{Nothing,Tuple{Int,Int}}` (last `(x, y)`); `InputState.lmb_down :: Bool`. +- `now` is the `game_loop!` **`frame` counter** (the value passed to `poll`), so the decay window is + measured in **loop frames**, not wall-clock — and since interactive `pace` is `sleep(1/30)`, one + frame ≈ 33 ms (this is the bridge between the frame-unit window and the "30 fps" tuning reasoning). +- Per-frame order inside `_poll_input!`: **drain events → stamp/clear `held` → `sweep_stale!(held, + now, window)` → `fold_input(state, now)`**. + +- `_poll_input!` each frame: (a) drains events, stamping the current tick on the held-key map for + every `KeyEvent` with action `key_press` **or** `key_repeat`, and removing the key on + `key_release`; (b) on a `MouseEvent`, updates the last-known cursor on **both** `mouse_move` + (bare motion, `button = mouse_none`) and `mouse_drag` (motion with a button down), and updates + the fire flag — SGR mouse has no per-frame "held" bit, so `lmb_down` is **edge-derived**: set + `true` on a `mouse_left` `mouse_press`/`mouse_drag`, set `false` on a `mouse_left` + `mouse_release`; (c) runs the decay sweep evicting stale held keys; (d) folds the held set + + `aim = last-known cursor (cx,cy)` + `fire = lmb_down || space-held` into one `Input`. The cursor + is emitted every frame (held steady between moves) and `aim` stays `nothing` until the first mouse + event. **No `φ` math here** — `aim_heading` lives in the sim (`_advance_ship!`), since it needs the + live ship position. +- **The pure logic is factored out of `_poll_input!`** into helpers that don't need a live terminal: + `sweep_stale!(map, now, window)` (eviction/refresh) and `fold_input(state, now)` (held-map + aim + + fire → `Input`). `_poll_input!` itself needs a live `TK.poll_event` (it depends on `INPUT_ACTIVE[]` + and raw-mode IO, `events.jl:128`) and so is exercised only by the human tier-2/3 check; the two + pure helpers get direct headless unit tests. This is the cleaner structure regardless of testing. +- **Tachikoma internal-API dependency:** `poll_event` / `enter_tui!` / `leave_tui!` / `toggle_mouse!` + are **not exported** by Tachikoma — the demo reaches them `TK.`-qualified (as it already does). + This couples the demo to Tachikoma 2.1.0's internal surface; the `[compat] Tachikoma = "2.1.0"` + pin already in `Project.toml` is what guards it. +- `run_game`'s interactive branch prints `\e[?1003h` after `enter_tui!` and `\e[?1003l` in the + `finally` before `leave_tui!`, so any-motion mouse tracking is enabled and always restored. +- The stale prose must all be rewritten for the twin-stick + held-key-decay model: the + `run_game` / `_poll_input` docstrings (`render_tachikoma.jl:96-97, 141-153`, "arrows / wasd turn & + thrust", momentary/ignored-release), the **file-header comment** (`render_tachikoma.jl:1-17`, + which also describes the momentary-capture stubs), and the `poll = frame -> _poll_input(term)` + callback wiring (`render_tachikoma.jl:113`) which becomes `_poll_input!(state, term, frame)`. +- The headless branch is untouched (`Input()` each frame, no mouse, no pacing) — the smoke test + continues to drive the real `run_game` over an `IOBuffer`. + +## Testing strategy + +**Headless (CI, automated):** +- Update `test_gameloop.jl` / `test_game.jl` / `test_draw.jl` to the new `Input` field set; keep + the existing invariants (loop completes, every frame a valid buffer, `tick_count` advances, + in-bounds). Replace `test_game.jl:13`'s `left`-turns-the-ship assertion with a strafe-velocity + assertion + a new aim-sets-`φ` assertion (see Input struct section). +- Add headless unit tests for the new pure logic. Most are driven through `tick!` with scripted + `Input` (no terminal): aim sets `φ` when provided and leaves it untouched when `nothing`; asteroid + bounce separates an overlapping pair below threshold; high closing speed fractures both and + increases shard count; ship dies on asteroid contact and is invulnerable on (re)spawn; replenish + restores the count to `N` when it drops below. Two are **direct unit tests of pure helpers**, NOT + through `tick!`: the held-key **`sweep_stale!`** decay/refresh logic (the rest of `_poll_input!` + needs a live `TK.poll_event`, so only this extracted helper is headless-testable), and the + wrap-aware **delta helper** (an edge-straddling pair returns the short-way signed delta). +- **Guard the fracture frame-conversion** (the riskiest new arithmetic — a wrong divisor or missing + clamp seeds `voronoi_shatter` outside the polygon, Silhouettes then returns fewer cells than + requested and `game.jl:170-178` truncates ranges). Extend `test_fracture.jl` with an **off-centre** + cell-space impact near the rim (e.g. `Point2(a.radius*0.8, 0.0)`) and assert **both** lossless + glyph preservation **and** that the shard count equals what was requested (i.e. the converted + impact landed inside the polygon). +- **Index-identity assertions are fragile under live collisions and must be hardened**, not just + renamed: both `test_gameloop.jl:101` (`g.asteroids[1].θ` after 30 ticks) and `test_game.jl:22-23` + (`asteroids[1].θ` after one tick) assume index 1 is the same body — but a shatter (`deleteat!`) or + replenish reorder breaks that. Assert "*some* asteroid rotated", or pin those scenarios' velocities + low enough that no shatter/replenish fires. (`test_gameloop.jl:61`'s in-bounds invariant is covered + by the push-apart re-wrap above.) +- `test_draw.jl:18-21` asserts `draw!` is deterministic w.r.t. `g`; the rewritten `_draw_ship!` must + stay a pure function of `g` (no RNG / wall-clock) for it to hold. + +**Determinism & the golden frame.** `frame60` is **not** a 60-tick evolution: `_run_golden` +(`test_golden.jl:32-41`) builds `new_game(Xoshiro(38))`, then **overrides** every asteroid's +position/velocity/ω/radius/`prep` and the ship's pose, and calls `draw!` **once** with **no tick +loop**. The only state that survives from the RNG stream is the asteroid **polygons**. Therefore: +- The golden is **invariant to every `tick!` change** in this work (movement, collisions, death, + replenish, edge-debug) — none of that logic runs in the golden. +- It regenerates for exactly **one** reason: the `_draw_ship!` rewrite (8-way glyph + plume + removal). The golden's ship is pinned at `φ = 0`, so the regenerated glyph must render a sensible + **nose-up** form there. +- The spawn-velocity bump is constrained to a **rescale of existing `rand` draws** (no added/removed + draws); since `_spawn_asteroid` draws the polygon *before* the velocity rands (`game.jl:31` vs + `:36`), this leaves the polygons — and thus the golden — unperturbed. If the bump ever changes + the draw count/order, the golden changes for that reason too, which must be called out. + +`test/golden/frame60.{sha256,txt}` is regenerated (`UPDATE_GOLDEN=1`). Per project rule +([[feedback-view-rendered-artifacts]]) the regenerated `frame60.txt` is **visually inspected** to +confirm the new ship glyph reads correctly and the scene stays coherent — a re-hash alone is not +sign-off. The merge-gating suite is **run independently** ([[feedback-independently-run-merge-checks]]), +not trusted on report. + +**Live terminal (human tier-2/3, not unit-testable):** +- WASD feels responsive (crisp on a kitty-class terminal; acceptable past the initial hitch + otherwise); ship nose tracks the cursor; LMB and Space both charge/fire; asteroids bounce and + occasionally shatter on hard hits; ship dies on contact, respawns with the blink; field keeps + ~N asteroids. Tested on at least one kitty-class and one non-kitty terminal. + +## Conventions (this change set) + +- Every new `.jl` file carries the `# SPDX-License-Identifier: MIT` header (as all existing + `src/`/`test/` files do). +- The demo has its **own** suite under `examples/asteroid_tui/`, run with its own project — not the + root TextMeasure suite: + `julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()'`, logging to + `test-logs/$CLAUDE_CODE_SESSION_ID.log` per the root CLAUDE.md. +- `examples/asteroid_tui/Manifest.toml` stays **gitignored** ([[demos-manifest-not-committed]]); + commit `Project.toml` only and instantiate on a fresh clone. +- The demo project pins `julia = "1.12"` and `Tachikoma = "2.1.0"` (`Project.toml`); new code and + tests must stay within that floor and must not need a Tachikoma API beyond 2.1.0's surface. +- **Env bootstrap (prerequisite):** this branch predates the #J `[sources]` fix, and + `examples/asteroid_tui/Project.toml` has **no `[sources]` table**, so plain `Pkg.test()` fails at + instantiate (local unregistered deps). Add a `[sources]` table — `TextMeasure = { path = "../.." }`, + `TextMeasureLayouts = { path = "../layouts" }`, `Silhouettes = { path = "../silhouettes" }` — so + `Pkg.test()` runs without a manual `Pkg.develop` (matching the on-main demos). This is **Task 0**, + before any TDD. + +## Files touched + +| File | Change | +|---|---| +| `src/input.jl` | `Input` fields → `up/down/left/right` strafe + optional `aim::Union{Nothing,Float64}`; remove `thrust`; docstring | +| `src/game.jl` | `GameState` gains `prev_debug` + `n_target` (init in `new_game`); direct-velocity strafe movement; `aim_heading` helper + aim→`φ` in `_advance_ship!`; wrap-aware **delta** helper; asteroid bounce/shatter; ship↔asteroid death; spawn protection (initial `invuln`); replenish (`n_target`, fixed RNG draws); honour `impact` (cell→polygon frame conversion) in `fracture_asteroid!`; **beam origin → nose at `Beam(...)` construction (`game.jl:88`)**; edge-triggered debug; spawn-velocity rescale | +| `src/entities.jl` | none expected (`Ship.φ` already exists; `prev_debug` lives in `GameState`) | +| `src/draw.jl` | 8-way directional ship glyph (nose one cell along `φ`, charge indicator relocated) + plume removed; **cut the auto-targeting leader** (`draw.jl:215-223`); update stale plume comments (`draw.jl:8` `COL_BEAM`, `:48-51`, `:165`/`:175`) | +| `src/render_tachikoma.jl` | stateful `InputState` (held-key map + last cursor + `lmb_down`); pure `sweep_stale!` + `fold_input` helpers; `_poll_input!` rewrite (stamp on press/repeat, mouse aim + edge-derived button, drain-loop timeout per existing caveat); `1003h/1003l` enable/restore in `run_game`; rewrite stale prose (`:1-17` header, `:96-97` & `:141-153` docstrings, `:113` poll wiring) | +| `src/AsteroidTUI.jl` | likely **no change** — new helpers stay unexported, reached by `using AsteroidTUI: …` (touch only if a genuinely public symbol is added) | +| `run.jl` | rewrite the controls banner (`run.jl:3-5`) for twin-stick | +| `test/test_gameloop.jl`, `test/test_game.jl`, `test/test_draw.jl` | new `Input` field set; replace `test_game.jl:13` turn-assertion; harden index-identity asserts (`test_gameloop.jl:101`, `test_game.jl:22-23`); keep `test_draw.jl:18-21` determinism; new headless collision/death/replenish + `sweep_stale!`/`fold_input`/wrap-delta unit tests | +| `test/test_fracture.jl` | callers pass `impact` as a cell-space offset (new frame contract); keep lossless-glyph-preservation assertions | +| `test/golden/frame60.{sha256,txt}` | regenerated (driven by the `_draw_ship!` change only) + visually verified; update the stale "thrust plume" comment in `test_golden.jl:24-25` (block `:21-31`) | + +## Open tuning knobs (resolved at implementation, not design) + +- Movement speed and friction coefficient. +- Decay-window length (in **loop frames**; ≈33 ms each at the `sleep(1/30)` pace). **Constraint, + not just taste:** it must exceed the worst-case inter-repeat interval (converted to frames) on the + slowest non-kitty autorepeat you support — otherwise a genuinely-held key is evicted between + repeats and stutters. Set it from that floor, then rely on the kitty release path for crispness. +- Visual-space row-scale factor for aim (cell aspect ≈ 2, refined by eye; applied as a multiplier). +- Asteroid spawn-velocity range and the bounce-vs-shatter **closing-speed** threshold. +- Replenish target `N` (defaults to the starting `n_asteroids`). + +All five are magnitudes only — none change the structure above (e.g. the spawn-velocity bump is a +rescale of existing draws, not a new distribution), so each is safe to tune at the terminal. diff --git a/docs/superpowers/specs/2026-06-13-demo-gallery-goal-prompt-design.md b/docs/superpowers/specs/2026-06-13-demo-gallery-goal-prompt-design.md new file mode 100644 index 0000000..f9b2b41 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-demo-gallery-goal-prompt-design.md @@ -0,0 +1,113 @@ +# `/goal` prompt — demo gallery, impeccable finish + +**Date:** 2026-06-13 · **Status:** design approved (knobs locked) + +## Purpose + +Design a `/goal` condition that drives Claude to elevate the 5-demo gallery +(#E #F #G #H #K) to an "impeccable finish" bar — grading each demo, redesigning or +replacing the weak ones, and iterating each as a **web mockup first** before porting to +its final medium (PDF / PNG / ANSI TUI). + +## The governing constraint + +`/goal`'s evaluator is a **fast model that only reads the conversation transcript**. It +never runs commands or opens files. "Better finish" is a *subjective visual* bar and the +evaluator **cannot see a PNG/PDF/terminal**. Therefore the entire design problem is: +**translate "impeccable" into checkpoints that Claude's own transcript output demonstrates.** + +This is the "green test ≠ visual sign-off" trap, sharpened — the judge here can't even see +the test. The answer is three independent, transcript-visible confirmations per artifact +(re-open-and-match, independent-reviewer PASS, green golden), plus a deliberate human gate. + +## Locked decisions + +| Decision | Choice | +|---|---| +| Scope | **Grade then redesign/replace.** Keep strong demos; licensed to replace any that can't reach the bar (asteroid TUI flagged test-gamed). | +| Mockup loop | **Mandatory for every demo**, TUI included (rendered as a styled fixed-pitch HTML grid), then ported. | +| Done bar | **Independent design-reviewer subagent sign-off, *then* human gate.** Subagents grade unattended; goal stops and hands the final aesthetic call to the user rather than auto-clearing. | +| Pass threshold | **≥ 8/10 on EVERY rubric axis.** One axis < 8 forces another iteration. | +| Turn bound | **~40 turns**, then stop and report progress. | + +## Deliverable shape — 3 coupled artifacts + +A 4,000-char condition can't carry the rigor alone, so it stays thin and *points at* files: + +1. **`DESIGN.md` + `PRODUCT.md`** (in `examples/`) — authored as the goal's step 0 if absent; + treated as authoritative if present. These are the anchor the reviewer scores against. + - **`PRODUCT.md`** — what the gallery proves about TextMeasure.jl, who reads it, and the + one claim each demo must make tangible. + - **`DESIGN.md`** — the craft rubric. Axes: **TYPE, PALETTE, COMPOSITION, + RESTRAINT/HIERARCHY, FINISH.** Extends `docs/superpowers/demos-house-style.md` and the + `frontend-design` skill. House-style values stay authoritative unless `DESIGN.md` + deliberately supersedes a specific value *and states why*. +2. **Design-reviewer subagent brief** — "you have no stake in this work; score this + screenshot against each `DESIGN.md` axis; PASS requires ≥ 8 on every axis." Lives inside + the goal text (dispatched fresh per artifact). +3. **The `/goal` condition string** (below) — the ≤ 4,000-char paste-able driver. + +## Per-demo pipeline (every stage leaves transcript evidence) + +``` +0. Author DESIGN.md + PRODUCT.md (once, if absent) +1. GRADE existing demo vs DESIGN.md → keep | refine | replace +2. MOCKUP: HTML/CSS (TUI = styled monospace grid), iterate via Playwright screenshots +3. PORT: render real media (Julia/Makie PDF/PNG, or captured ANSI frames) +4. RE-OPEN final media + screenshot → confirm in writing it matches the mockup +5. REVIEW: fresh design-reviewer subagent scores FINAL screenshot → PASS ≥ 8 every axis +6. Golden test green; regenerated goldens visually verified before commit; no Manifest committed +``` + +Stages 4–6 are the three independent confirmations that make a transcript-only evaluator +trustworthy. + +## The paste-able `/goal` condition + +> Paste everything below after `/goal ` (it is under 4,000 characters). + +``` +GOAL — Demo gallery (#E #F #G #H #K), impeccable finish. + +SETUP: Read docs/superpowers/demos-house-style.md and, if present, examples/DESIGN.md + examples/PRODUCT.md. If either is missing, author it BEFORE any demo work. PRODUCT.md = what the gallery proves about TextMeasure.jl, who reads it, and the one claim each demo must make tangible. DESIGN.md = the craft rubric with axes TYPE, PALETTE, COMPOSITION, RESTRAINT/HIERARCHY, FINISH, extending demos-house-style.md and the frontend-design skill; house-style values stay authoritative unless DESIGN.md deliberately supersedes a specific value and says why. Invoke the frontend-design skill when authoring DESIGN.md and every mockup. FONTS: if Fraunces or IBM Plex Mono are not yet pinned as static TTFs in examples/fonts/, source them (OFL) before any port; expect to regenerate every golden against the new faces and visually verify each before commit. + +PER DEMO, run this pipeline and surface the evidence in THIS transcript: +1. GRADE the current demo vs DESIGN.md -> verdict keep | refine | replace. You MAY replace any demo that can't reach the bar; the asteroid TUI (#E) is a known test-gamed candidate. +2. MOCKUP FIRST: build an HTML/CSS mockup (TUI = styled fixed-pitch monospace grid), iterate with Playwright screenshots until it clears DESIGN.md. +3. PORT to the real medium (Julia/Makie PDF/PNG, or captured ANSI frames for the TUI). +4. RE-OPEN the rendered final media, screenshot it, and confirm IN WRITING it matches the signed-off mockup; name any geometry the medium couldn't hit. +5. REVIEW: dispatch a FRESH design-reviewer subagent that has no stake in the work; it scores the FINAL artifact's screenshot against EVERY DESIGN.md axis and must return PASS with >=8/10 on every axis. Any axis <8 = another iteration. Paste its verdict. +6. The demo's golden test passes; any regenerated golden is visually verified before commit; no demo Manifest.toml is committed. + +MET WHEN, for all 5 demos, this transcript contains: a grade verdict, a final-media-matches-mockup confirmation, an independent reviewer PASS (>=8 every axis), and a green golden — AND you have posted a final summary table (demo · verdict · per-axis scores · media path) and explicitly handed off for the user's final visual sign-off. Do NOT self-clear past that hand-off. + +Stop and report progress if not met after 40 turns. +``` + +## House-style redesign (2026-06-13, post-approval) + +After the prompt was designed, `demos-house-style.md` was redesigned under Impeccable + +frontend-design into the **"editorial instrument"** spine, and `DESIGN.md` / `PRODUCT.md` +updated to match: + +- **Type:** Fraunces (display/text optical sizes) × IBM Plex Mono — pinned as **static** + TTFs in `examples/fonts/` for golden determinism. Ramp re-founded on √2 (the paper ratio): + 9 / 11 / 16 / 22 / 31 / 44 pt. +- **Palette:** two layers — identity (PAPER `#FBFAF7` off-white, INK `#1E1C1A`, BRASS + signature `#B5793C`) + data-encoding-only (blue/green/red/gray). Pure white retired. +- **#G** subject is **California** (not Vermont). + +**Prerequisite the goal must handle first:** source + pin Fraunces and IBM Plex Mono before +any mockup→port, then expect to **regenerate every golden** against the new faces (visually +verified before commit, per house style). This is the largest single cost of the redesign. + +## Notes / open follow-ups + +- **DESIGN.md quality is load-bearing** — the reviewer's bar is only as good as this doc. If + authored unattended in turn 0 it may be weak. Recommended precursor: co-author + `DESIGN.md` + `PRODUCT.md` with the user *before* running the goal, so the bar is + trustworthy and the goal just executes against it. The prompt works either way. +- Pair the goal with **auto mode** so each turn runs unattended (per `/goal` docs). +- The "human gate" is encoded as the terminal hand-off: the condition is met at + *posted-and-awaiting-sign-off*, not at *user-approved* — the goal stops there by design. +``` diff --git a/examples/DESIGN.md b/examples/DESIGN.md new file mode 100644 index 0000000..266235b --- /dev/null +++ b/examples/DESIGN.md @@ -0,0 +1,106 @@ + +# DESIGN.md — Craft rubric for the demo gallery + +*Scope: the five-piece gallery (#E #F #G #H #K). This is the **scoring rubric** the +design-reviewer subagent applies. It sits on top of two documents and never contradicts +them silently:* + +- [`docs/superpowers/demos-house-style.md`](../docs/superpowers/demos-house-style.md) — the + **locked constants** (type ramp, palette, footer, hairlines). These are hard values. +- [`PRODUCT.md`](PRODUCT.md) — the **one claim** each piece must make tangible. + +**Supersede rule:** a piece MAY deviate from a house-style constant only if this file names +the specific value, the piece it applies to, and *why* the deviation serves the claim. An +unexplained deviation is a defect, not a choice. + +## North star + +> **Authored by one hand, exact to the pixel, memorable in one detail.** + +The gallery is the proof that a *measurement* library measures correctly. Therefore the +overriding aesthetic value is **earned exactness**: every alignment looks deliberate because +it is computed, not nudged. Restraint over decoration. One bold move per piece, executed +precisely, beats five timid ones. Coherence ≠ uniformity: a TUI, a magazine, and a map +should *feel* different while sharing the spine. + +This is **editorial/print-compositor** discipline, not dashboard or "AI-slop" aesthetics. +Banned on sight: Inter/Roboto/Arial/system-font defaults, purple-on-white gradients, evenly +distributed timid palettes, centered-everything layouts, drop-shadow soup, emoji as +iconography. + +## How to score (for the reviewer) + +Score the **final rendered artifact's screenshot** — not the mockup, not the code — on each +of the five axes below, 0–10. **PASS requires ≥ 8 on every axis.** A single axis < 8 fails +the piece and forces another iteration. For each axis, cite the specific pixel/region that +drove the score; "looks nice" is not a score. + +Anchor points: **10** = a working compositor would ship it. **8** = ships, with a nit you +had to hunt for. **6** = a literate viewer notices something off in 2s. **≤ 4** = the claim +is undermined (looks like the measurement is wrong). + +### Axis 1 — TYPE +Type ramp obeyed exactly (caption 9 / body 11 / subhead 16 / title 22 / deck 31 / display +44 pt — the √2 scale; no in-between sizes). Display-to-body 4×, title-to-body 2× contrast +held. Correct face per role: **Fraunces** (display optical size for mastheads, text optical +size for body / #K columns) for serif roles; **IBM Plex Mono** for captions, labels, stats, +axis ticks, footers, and the #E TUI. Body ragged-right except #K. **Fails < 8 if:** an +off-ramp size appears, contrast is softened, Fraunces/Plex-Mono roles are swapped, the wrong +Fraunces optical size is used (display opsz in body, or text opsz in a masthead), or +rivers/widows/orphans mar a column. + +### Axis 2 — PALETTE +Two layers honored. **Identity** = PAPER off-white (never `#FFFFFF`) + INK warm near-black + +BRASS signature; brass carries mastheads, structural rules, and the footer middot. **Data** += BLUE/GREEN/RED/GRAY used ONLY to encode (BLUE=data, GREEN=good, RED=problem, +GRAY=structure), never for identity. Alpha values for hairlines/rules as specified. +**Fails < 8 if:** a pure-white surface appears, any unlisted hue shows up, a data color does +identity work or is used off-meaning, the signature brass is absent from a piece, or a +rule's weight/alpha/color is eyeballed instead of spec. + +### Axis 3 — COMPOSITION +36 px outer margin on print pieces; content sits on a visible underlying grid; alignments +are exact (shared baselines, flush edges, true optical centering where used). The one bold +spatial move (asymmetry, overlap, a dominant masthead) is intentional and balanced. +**Fails < 8 if:** an element is a few px off its grid line, margins drift, or the layout is +inert/centered-by-default with no point of view. + +### Axis 4 — RESTRAINT / HIERARCHY +One clear focal point; the eye knows where to land first. No decoration that doesn't carry +information. Negative space is used as a material. **Fails < 8 if:** two elements compete for +first read, or anything is ornamental-only (a gradient, a shadow, a chip that means nothing). + +### Axis 5 — FINISH +The pixel-level tell that the measurement is exact: chip widths hug their text, bars end +where their labels do, silhouette prose kisses the boundary with no overflow/no gap, TUI +cells align, footer baseline sits on the inner margin line. **Fails < 8 if:** any seam, +half-cell, 1px misalignment, clipped glyph, or "almost lines up" is visible at 1× zoom. + +## Per-piece design direction (the one memorable move) + +These are the bold-but-disciplined directions each piece commits to. The reviewer checks the +piece *delivers* its move, not merely that it's inoffensive. + +- **#E Asteroid TUI** — *living type.* The memorable thing is prose that visibly **reflows + as the silhouette rotates** and **fractures on a word boundary** on impact. Phosphor-CRT + restraint: near-black field, accents only for state. Motion must read as physics, not + decoration. (TUI exempt from px footer; bottom border row carries the footer string.) +- **#F DOI Infographic** — *data you can measure with a ruler.* The move is bars and chips + whose lengths are visibly text-derived; a reader could verify a width by eye. BLUE data, + GRAY structure. +- **#G California Silhouette** — *text as the shape.* The move is prose filling the + California outline so the negative space *is* the state, GREEN fill, with the boundary + hugged flush. +- **#H The Newer Yorker** — *print fidelity.* The move is a masthead-led editorial spread + ("A Correctness Exhibit", RED data-subtitle) with a skyline inset, holding a real + magazine's baseline discipline. Display-44 masthead is the single focal point: an **ink + wordmark** (never brass — an amber wordmark would soften the authority), with brass doing + the **structural rule, the dateline, and a small registration tick** beneath it. +- **#K Knuth–Plass** — *the honest justify.* The move is optimal-fit justified columns with + even colour and controlled rivers (RED river overlay as the diagnostic), the only piece + that earns full-justify because correct measurement makes it possible. + +## What "done" looks like + +Every piece: claim from `PRODUCT.md` legible at a glance, ≥ 8 on all five axes from an +independent reviewer, golden test green, and the five sitting together as one authored set. diff --git a/examples/PRODUCT.md b/examples/PRODUCT.md new file mode 100644 index 0000000..50128f5 --- /dev/null +++ b/examples/PRODUCT.md @@ -0,0 +1,53 @@ + +# PRODUCT.md — The TextMeasure.jl Demo Gallery + +*Scope: the five-piece demo gallery (#E #F #G #H #K) under `examples/`. Paired with +[`DESIGN.md`](DESIGN.md) (the craft rubric) and +[`docs/superpowers/demos-house-style.md`](../docs/superpowers/demos-house-style.md) (the +locked typographic/colour constants).* + +## What the gallery is for + +The gallery is the **proof, not the documentation**. The README tells you TextMeasure.jl is +a backend-agnostic *measure-once, layout-many* engine. The gallery makes you *believe* it — +by showing the same measured text doing things an HTML canvas can't, in media an HTML +canvas can't reach: a print magazine, a state silhouette, a live terminal. + +A visitor should leave with one sentence: **"It measures text once, then lays it out +anywhere — paper, a shape, a moving terminal — and the geometry is exact."** + +## Who reads it + +1. **The skeptical Julia/graphics engineer** evaluating the library for a real layout job. + They will not read source first; they look at the gallery, and if it looks + hand-assembled and exact, they trust the code. Sloppy kerning or a misaligned baseline + reads as "the measurement is wrong" — fatal for a *measurement* library. +2. **The design-literate browser** (HN, Julia Discourse, a conference hallway). They judge + craft in two seconds. The gallery has to survive that glance. + +## The one claim each demo must make tangible + +Each piece exists to make exactly **one** capability undeniable. If a piece doesn't make +its claim legible at a glance, it has failed regardless of how pretty it is. + +| # | Piece | The single claim it must prove | +|---|---|---| +| **#E** | Asteroid TUI | *Measure once, reflow live.* Shape-packed prose tumbles and re-flows inside rotating silhouettes and fractures on word boundaries on impact — all from cached measurements, in a terminal, at frame rate. The headline. | +| **#F** | DOI Infographic | *Exact data-driven typography.* Citation bars and tag chips whose widths are computed from measured text, not guessed — alignment that only holds if measurement is exact. | +| **#G** | California Silhouette | *Text conforms to arbitrary shape.* Prose `shape_pack`ed into a non-rectangular outline, hugging the boundary with no overflow and no re-measure. | +| **#H** | The Newer Yorker | *Editorial fidelity.* A magazine spread ("A Correctness Exhibit") where measured layout matches a print compositor's eye — margins, baselines, and a skyline inset that all line up. | +| **#K** | Knuth–Plass | *Justification done right.* Optimal-fit justified columns whose even rivers and exact line widths are only possible with correct advance widths. The one piece that *is* full-justify. | + +## Out of scope (so the gallery stays a gallery) + +No rendering engine, no annotation/repel/treemap consumers, no UAX-#14 line-breaking, CJK, +or hyphenation (see `CLAUDE.md`). The gallery demonstrates the *measurement* contract; it +does not grow new library surface to look impressive. + +## Definition of a piece that earns its place + +A piece stays in the gallery only if it **(a)** makes its one claim legible at a glance and +**(b)** clears every axis of `DESIGN.md` at ≥ 8/10. A piece that is technically interesting +but visually muddled, or pretty but claim-less, gets redesigned or cut. Coherence across the +five is not optional: they share the house-style spine so the gallery reads as *authored by +one hand*, not assembled from five contributors. diff --git a/examples/asteroid_tui/Project.toml b/examples/asteroid_tui/Project.toml new file mode 100644 index 0000000..ece992f --- /dev/null +++ b/examples/asteroid_tui/Project.toml @@ -0,0 +1,41 @@ +name = "AsteroidTUI" +uuid = "e57a1d00-a5e0-4b1c-9f3a-1b2c3d4e5f60" +version = "0.1.0" +authors = ["TextMeasure.jl demos milestone"] + +[deps] +FIGlet = "3064a664-84fe-4d92-92c7-ed492f3d8fae" +GeometryBasics = "5c1252a2-5f33-56bf-86c9-59e7332b4326" +PrecompileTools = "aea7be01-6a6a-4083-8856-8a6e6704d82a" +Printf = "de0858da-6303-5e67-8744-51eddeeeb8d7" +Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +SHA = "ea8e919c-243c-51af-8825-aaa63cd721ce" +Silhouettes = "b1f0d3e2-6a4c-4d8e-9f1a-2c3b4d5e6f70" +Tachikoma = "468859d6-42d8-48b7-8ad9-1d312e0e3b0a" +TextMeasure = "06791c1d-2336-41e1-bd6f-a74c63395da6" +TextMeasureLayouts = "57b0e3ea-cc01-4cc3-9e7e-6e97d1609b9f" +Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" + +# Local-path deps so a plain `Pkg.instantiate()` / `Pkg.test()` works without a +# manual `Pkg.develop` step (Julia 1.11+ [sources] feature). +[sources] +TextMeasure = { path = "../.." } +TextMeasureLayouts = { path = "../layouts" } +Silhouettes = { path = "../silhouettes" } + +[compat] +FIGlet = "0.2.2" +GeometryBasics = "0.5.10" +PrecompileTools = "1" +Printf = "1.11.0" +Random = "1.11.0" +SHA = "0.7" +Tachikoma = "2.1.0" +Unicode = "1" +julia = "1.12" + +[extras] +Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" + +[targets] +test = ["Test"] diff --git a/examples/asteroid_tui/README.md b/examples/asteroid_tui/README.md new file mode 100644 index 0000000..d82c601 --- /dev/null +++ b/examples/asteroid_tui/README.md @@ -0,0 +1,66 @@ + +# Asteroid TUI (#E) — Tachikoma ASCII Asteroid Blaster + +The headline demo for [TextMeasure.jl](../../): a terminal asteroid blaster whose +prose is **shape-packed** into procedural silhouettes, fractured on word +boundaries when you hit one, and reflowed live as asteroids rotate — all driven by +*measure-once, layout-many*. + +It composes the wave-1 building blocks: + +- **`subprep`** (TextMeasure #A) — slice an already-measured paragraph at a word + boundary without re-measuring, for impact fracture. +- **`FigletBackend`** (TextMeasure #B) — variable-width figlet measurement at the + terminal cell level (display type). +- **`shape_pack` / `raster_chord_fn`** (TextMeasureLayouts #C) — pack words into a + cell-grid silhouette. +- **`asteroid_polygon` / `voronoi_shatter` / `rasterize`** (Silhouettes #D) — + procedural shapes, fracture, and rasterization to the cell grid. + +## Architecture + +The game core is **renderer-agnostic**: a pure `tick!(state, input)` advances +physics and a pure `draw!(buf, state)` paints a `CellBuffer` (a `Char` grid + 256 +-color + bold mask). The interactive Tachikoma renderer drains that `CellBuffer` to +the screen; the CI test drains it to a checksum. Nothing in the core touches a +terminal, so the headline behavior is fully testable headless. + +## Run (interactive) + +```bash +julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.instantiate()' +julia --project=examples/asteroid_tui examples/asteroid_tui/run.jl +``` + +Requires Julia ≥ 1.12 (Tachikoma). Linux/macOS only (ANSI / raw-mode). Controls: +arrows/`wasd` move & turn, space charges (release to fire), `?` toggles the debug +overlay, `q` quits. + +## Test (headless) + +```bash +julia --project=examples/asteroid_tui -e 'using Pkg; Pkg.test()' +``` + +The suite checks three things independently: + +- **Render regression** — a deterministic, seeded showcase scene is drawn to a + `CellBuffer` and checksummed against a committed golden (`test/golden/frame60.sha256`); + a human-readable snapshot of the 116×36 frame lives at `test/golden/frame60.txt`. +- **Glyph preservation** (`test/test_fracture.jl`) — when an asteroid is hit, `subprep` + slices its already-measured prose so every word survives in exactly one shard, in + original order (`rebuilt == original`). +- **Tick-loop determinism / physics** (`test/test_game.jl`) — seeded `tick!` over the + game core (motion, charge, projectiles, respawn, invulnerability). + +### The golden frame + +The committed snapshot (`test/golden/frame60.txt`) is a 116×36 showcase scene: a +large dominant asteroid whose descriptive prose is **shape-packed inside its +silhouette** as a rounded text-mass, a smaller intact drifter receding to the upper +right, and the player's ship hunting below — each asteroid labelled by a closed +`┌─ d:… v:… ─┐` callout box joined to its body by a connector leader, with short +motion trails behind the moving bodies and a thrust plume under the ship. Every +body carries a nonzero velocity so the still telegraphs motion. The scene is tuned +for legibility — no overlapping labels, no off-screen clipping. Open +`test/golden/frame60.txt` to view the full frame. diff --git a/examples/asteroid_tui/render_html.jl b/examples/asteroid_tui/render_html.jl new file mode 100644 index 0000000..89a442e --- /dev/null +++ b/examples/asteroid_tui/render_html.jl @@ -0,0 +1,142 @@ +# SPDX-License-Identifier: MIT +# +# Gallery export: render CellBuffer frames to a colored HTML artifact so the #E +# showcase can be SCORED in its real palette (phosphor field + state accents), +# which the plain-text golden discards. Not part of the test path — a capture tool. +# +# julia --project=examples/asteroid_tui examples/asteroid_tui/render_html.jl +# +# Emits two files under examples/asteroid_tui/build/: +# frame_current.html — the committed golden scene, in color (grade baseline) +# filmstrip.html — intact | reflow(rotated) | fracture, the refine direction + +using AsteroidTUI +using AsteroidTUI: new_game, draw!, CellBuffer, CellBackend, nrows, ncols, fracture_asteroid! +import TextMeasure +import GeometryBasics as GB +using Random + +const FONT = abspath(joinpath(@__DIR__, "..", "fonts", "IBMPlexMono", "IBMPlexMono-Regular.ttf")) + +# xterm-256 → hex for exactly the indices draw.jl uses (+ a default ink). +const PALETTE = Dict( + 0x00 => "#d8d8d8", # terminal default ink (unused on drawn glyphs) + 0xf0 => "#585858", # 240 border / footer + 0xf3 => "#767676", # 243 trail + 0xf4 => "#808080", # 244 callout + 0xfa => "#bcbcbc", # 250 intact prose + 0xdf => "#ffd7af", # 223 warm shard prose + 0x33 => "#22d3ee", # 51 ship hull (cyan) + 0xe2 => "#fbbf24", # 226 beam (amber) + 0x2d => "#00d7ff", # 45 debug + 0x89 => "#b5793c", # 137 brass — house-style signature (footer middot) +) +hexof(fg::UInt8) = get(PALETTE, fg, "#d8d8d8") + +esc(c::Char) = c == '<' ? "<" : c == '>' ? ">" : c == '&' ? "&" : string(c) + +# One CellBuffer → a
 of color spans. Runs of equal (fg,bold) are coalesced.
+function cell_pre(buf::CellBuffer)
+    io = IOBuffer()
+    print(io, "
")
+    for r in 1:nrows(buf)
+        fg = nothing; bold = false; open = false
+        for c in 1:ncols(buf)
+            ch = buf.chars[r, c]; f = buf.fg[r, c]; b = buf.bold[r, c]
+            if !open || f != fg || b != bold
+                open && print(io, "")
+                print(io, "")
+                fg = f; bold = b; open = true
+            end
+            print(io, esc(ch))
+        end
+        open && print(io, "")
+        print(io, '\n')
+    end
+    print(io, "
") + return String(take!(io)) +end + +function page(title, panels) # panels :: Vector{(label, pre_html)} + io = IOBuffer() + print(io, """$title + +""") + for (label, pre) in panels + print(io, "
", pre, "
", label, "
") + end + print(io, "") + return String(take!(io)) +end + +# ---- scenes ----------------------------------------------------------------- + +const HERO = + "Born from the shattering of some long-dead world, this drifting massif of nickel and " * + "shadowed ice has wandered the cold reaches for an age, its pitted face a record of every " * + "blow the dark has dealt it, tumbling on without haste or heading through the long night " * + "between the scattered stars." + +_prep(s) = TextMeasure.prepare(CellBackend(), s) + +# A single dominant hero asteroid centered in a w×h field; returns (game, hero). +function hero_scene(; w, h, θ, radius = 11.0, seed = 38) + g = new_game(Xoshiro(seed); width = w, height = h, n_asteroids = 1) + g.ship.alive = false # filmstrip panels focus on the body + a = g.asteroids[1] + a.x = w / 2; a.y = h / 2 + 1 + a.vx = 0.20; a.vy = 0.08; a.ω = 0.02; a.θ = θ + a.radius = radius; a.prep = _prep(HERO) + return g, a +end + +mkpath(joinpath(@__DIR__, "build")) + +const MED = "Porous, fast, and faintly glittering, it drifts where the sunlight finally " * + "grows too thin to warm a stone." + +# (1) current committed golden scene, in color (mirrors test/test_golden.jl::_run_golden) +function run_golden() + g = new_game(Xoshiro(38); width = 116, height = 36, n_asteroids = 2) + g.ship.x = 58.0; g.ship.y = 31.0; g.ship.φ = 0.0; g.ship.vx = 0.0; g.ship.vy = -0.15 + a1, a2 = g.asteroids + a1.x = 32.0; a1.y = 18.0; a1.vx = 0.22; a1.vy = 0.10; a1.ω = 0.012; a1.radius = 10.0; a1.prep = _prep(HERO) + a2.x = 93.0; a2.y = 12.0; a2.vx = -0.18; a2.vy = 0.06; a2.ω = -0.02; a2.radius = 6.0; a2.prep = _prep(MED) + buf = CellBuffer(g.height, g.width); draw!(buf, g); return buf +end +_buf = run_golden() +write(joinpath(@__DIR__, "build", "frame_current.html"), + page("frame_current", [("the committed 116×36 golden, in its real palette", cell_pre(_buf))])) + +# (2) filmstrip: intact → reflow(rotated) → fracture +W, H = 54, 30 +g0, _ = hero_scene(; w = W, h = H, θ = 0.0) +b0 = CellBuffer(H, W); draw!(b0, g0) + +g1, _ = hero_scene(; w = W, h = H, θ = 1.15) +b1 = CellBuffer(H, W); draw!(b1, g1) + +g2, a2 = hero_scene(; w = W, h = H, θ = 0.5, radius = 9.0) +fracture_asteroid!(g2, 1, GB.Point2{Float64}(2.0, -1.0)) +b2 = CellBuffer(H, W); draw!(b2, g2) + +write(joinpath(@__DIR__, "build", "filmstrip.html"), + page("filmstrip", [ + ("measure once — prose packed into the silhouette", cell_pre(b0)), + ("reflow live — same cached measurement, body rotated", cell_pre(b1)), + ("fracture — sliced on word boundaries, no re-measure", cell_pre(b2)), + ])) + +println("wrote build/frame_current.html and build/filmstrip.html") diff --git a/examples/asteroid_tui/run.jl b/examples/asteroid_tui/run.jl new file mode 100644 index 0000000..4f948f9 --- /dev/null +++ b/examples/asteroid_tui/run.jl @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MIT +# +# Human-play entry point. Run at a real terminal: julia --project run.jl +# Controls: WASD strafe (the mouse aims the ship), LMB or space charges (release to fire), +# ? toggles the debug overlay, q / Esc / Ctrl-C quit. +using AsteroidTUI + +# The game is interactive: it reads raw-mode keystrokes and quits on `q`. Without a +# TTY there are no keys to read and no quit signal, so a bare `run_game()` would loop +# forever. Refuse to start headless (rather than hang) and point at the headless API. +if !(stdout isa Base.TTY) + println(stderr, """ + AsteroidTUI needs an interactive terminal (a TTY) to play. + Run it directly: julia --project run.jl + For a bounded, no-TTY run (tests/CI), call: + AsteroidTUI.run_game(; io = IOBuffer(), max_frames = 60)""") + exit(1) +end + +AsteroidTUI.run_game(; seed = parse(Int, get(ENV, "SEED", "0"))) diff --git a/examples/asteroid_tui/src/AsteroidTUI.jl b/examples/asteroid_tui/src/AsteroidTUI.jl new file mode 100644 index 0000000..2c2d9cc --- /dev/null +++ b/examples/asteroid_tui/src/AsteroidTUI.jl @@ -0,0 +1,65 @@ +# SPDX-License-Identifier: MIT +""" + AsteroidTUI + +Tachikoma ASCII Asteroid Blaster (#E, demos milestone) — the headline demo for +TextMeasure.jl's *measure-once-layout-many* primitive in terminal space. + +Architecture: a renderer-agnostic [`CellBuffer`](@ref) (a `Char` grid + 256-color ++ bold) is painted by a pure `draw!(buf, state)` over a pure `tick!(state, input)` +game core. The core consumes `prepare`/`subprep`/`FigletBackend` (TextMeasure), +`shape_pack`/`raster_chord_fn` (TextMeasureLayouts), and `asteroid_polygon`/ +`voronoi_shatter`/`rasterize` (Silhouettes). The CI golden test checksums the +`CellBuffer` and never instantiates a renderer; the Tachikoma renderer (interactive +only) drains the same buffer to the screen. +""" +module AsteroidTUI + +using TextMeasure +using TextMeasureLayouts +using Silhouettes +using FIGlet # activates TextMeasureFigletExt +import GeometryBasics as GB +using Random + +# Smoke check that the FIGlet extension loaded (cell-space measurement available). +function _ext_loaded() + return Base.get_extension(TextMeasure, :TextMeasureFigletExt) !== nothing +end + +include("cellbuffer.jl") +include("cellbackend.jl") +include("prose.jl") +include("pack.jl") +include("entities.jl") +include("input.jl") +include("game.jl") +include("draw.jl") +include("render_tachikoma.jl") + +export CellBuffer, clear!, put_char!, put_string!, checksum, to_text +export CellBackend +export asteroid_prose, PROSE_VARIANTS +export pack_prose_into, PackedProse +export GameState, new_game, tick!, draw!, kill_ship!, ship_visible +export Input, ScriptedInput, next_input! +export run_game, game_loop!, step_frame!, drain_to_tachikoma! + +# Precompile the first-frame call graph so it's baked into this package's cache +# instead of JIT-compiled on every fresh `run_game` (~28s TTFX otherwise). The +# headless `run_game(io=IOBuffer())` path drives new_game → tick! (all resolvers) → +# draw! → the Tachikoma render drain — the bulk of that cost. We also force one +# fracture (voronoi_shatter/subprep rarely fire in a few slow-asteroid frames) and +# the aim/strafe/fire input arms. This lengthens THIS package's precompile step, +# but that result is cached and amortised across every launch. +using PrecompileTools: @compile_workload + +@compile_workload begin + g = run_game(; width = 48, height = 20, seed = 0, max_frames = 3, io = IOBuffer()) + isempty(g.asteroids) || fracture_asteroid!(g, 1, GB.Point2{Float64}(0.0, 0.0)) + g2 = new_game(Xoshiro(1); width = 48, height = 20) + tick!(g2, Input(up = true, aim = (10.0, 3.0))) + tick!(g2, Input(fire = true)); tick!(g2, Input(fire = false)) +end + +end # module diff --git a/examples/asteroid_tui/src/cellbackend.jl b/examples/asteroid_tui/src/cellbackend.jl new file mode 100644 index 0000000..177d224 --- /dev/null +++ b/examples/asteroid_tui/src/cellbackend.jl @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: MIT +import TextMeasure +import Unicode + +""" + CellBackend() + +A zero-config measurement backend where every grapheme cluster is exactly one +terminal cell wide and a line advances exactly one row. This makes `shape_pack` +output land on integer cell coordinates, which is what the cell-grid silhouette +packing needs. (A fourth instance of CLAUDE.md's "subtype + two methods" pattern.) +""" +struct CellBackend <: TextMeasure.AbstractMeasurementBackend end + +# length(graphemes(text)) directly — no collect (avoids an allocation and a known +# ZWJ-emoji stdlib edge case in collecting the iterator; mirrors MonospaceBackend). +TextMeasure.measure(::CellBackend, text::AbstractString) = + Float64(length(Unicode.graphemes(text))) + +TextMeasure.font_metrics(::CellBackend) = TextMeasure.FontMetrics(1.0, 0.0, 1.0) diff --git a/examples/asteroid_tui/src/cellbuffer.jl b/examples/asteroid_tui/src/cellbuffer.jl new file mode 100644 index 0000000..86b3b35 --- /dev/null +++ b/examples/asteroid_tui/src/cellbuffer.jl @@ -0,0 +1,57 @@ +# SPDX-License-Identifier: MIT +using SHA: sha256 + +""" + CellBuffer(rows, cols) + +Renderer-agnostic terminal frame: a `Char` grid plus 256-color foreground and a +bold mask. `chars[row, col]`, `row == 1` is the top. Both interactive renderers +(Tachikoma, future raw-ANSI) drain a `CellBuffer`; the CI golden test checksums it +without instantiating any renderer. +""" +struct CellBuffer + chars :: Matrix{Char} + fg :: Matrix{UInt8} # Color256 index; 0x00 == terminal default + bold :: BitMatrix +end + +CellBuffer(rows::Integer, cols::Integer) = + CellBuffer(fill(' ', rows, cols), zeros(UInt8, rows, cols), falses(rows, cols)) + +nrows(b::CellBuffer) = size(b.chars, 1) +ncols(b::CellBuffer) = size(b.chars, 2) +inbounds(b::CellBuffer, r::Integer, c::Integer) = 1 <= r <= nrows(b) && 1 <= c <= ncols(b) + +function clear!(b::CellBuffer) + fill!(b.chars, ' '); fill!(b.fg, 0x00); fill!(b.bold, false); return b +end + +function put_char!(b::CellBuffer, r::Integer, c::Integer, ch::Char; fg::UInt8=0x00, bold::Bool=false) + inbounds(b, r, c) || return b + @inbounds (b.chars[r, c] = ch; b.fg[r, c] = fg; b.bold[r, c] = bold) + return b +end + +function put_string!(b::CellBuffer, r::Integer, c::Integer, s::AbstractString; fg::UInt8=0x00, bold::Bool=false) + col = c; row = r + for ch in s + if ch == '\n' + row += 1; col = c; continue + end + put_char!(b, row, col, ch; fg=fg, bold=bold); col += 1 + end + return b +end + +# Canonical, version-stable byte encoding: dims, then chars (UTF-8), then fg, then bold. +function _canonical_bytes(b::CellBuffer) + io = IOBuffer() + write(io, UInt32(nrows(b))); write(io, UInt32(ncols(b))) + for ch in b.chars; write(io, codeunits(string(ch))); write(io, 0x00); end + write(io, b.fg) + write(io, UInt8.(b.bold)) + return take!(io) +end + +checksum(b::CellBuffer) = bytes2hex(sha256(_canonical_bytes(b))) +to_text(b::CellBuffer) = join((String(@view b.chars[r, :]) for r in 1:nrows(b)), '\n') diff --git a/examples/asteroid_tui/src/draw.jl b/examples/asteroid_tui/src/draw.jl new file mode 100644 index 0000000..bb21fdb --- /dev/null +++ b/examples/asteroid_tui/src/draw.jl @@ -0,0 +1,243 @@ +# SPDX-License-Identifier: MIT +import Printf +import GeometryBasics as GB + +const COL_PROSE = 0xfa # 250 grey — intact asteroid prose +const COL_SHARD = 0xa7 # 167 red — fracture-shard prose (impact STATE, pops vs grey; + # distinct from the brass signature's warm hue) +const COL_SHIP = 0x33 # 51 cyan — ship hull +const COL_BEAM = 0xe2 # 226 yellow— projectiles + charge indicator +const COL_TAG = 0xf4 # 244 grey — callout boxes +const COL_TRAIL = 0xf3 # 243 grey — motion trails +const COL_DEBUG = 0x2d # 45 cyan — debug bbox overlay +const COL_BORDER = 0xf0 # 240 dim grey — frame + footer +const COL_RIM = 0xee # 238 dimmer grey — silhouette rim (the boundary the prose hugs) +const COL_BRASS = 0x89 # 137 muted brass — the house-style signature (footer middot) +const CHARGE_GLYPH = (' ', '·', '*', '─', '\\', '✸') # index = charge+1 (1-based) + +# House-style gallery footer (docs/superpowers/demos-house-style.md §3): every demo +# carries `TextMeasure.jl · ` (middot U+00B7). The print pieces put it +# bottom-left on the inner margin; the TUI analogue reserves the bottom border row. +const FOOTER = "TextMeasure.jl · Asteroid TUI" + +# --- border + footer --------------------------------------------------------- + +# Box-drawing border on the outermost ring, with the attribution footer inlaid into +# the BOTTOM rule (left-anchored, house-style §3/§4). Drawn LAST in `draw!`, so any +# glyph that drifted to the edge is overwritten — viewport clipping reads as a +# deliberate frame edge, not dangling fragments. +function _draw_border!(buf::CellBuffer) + nr, nc = nrows(buf), ncols(buf) + (nr < 2 || nc < 2) && return buf + put_char!(buf, 1, 1, '┌'; fg = COL_BORDER) + put_char!(buf, 1, nc, '┐'; fg = COL_BORDER) + put_char!(buf, nr, 1, '└'; fg = COL_BORDER) + put_char!(buf, nr, nc, '┘'; fg = COL_BORDER) + for c in 2:(nc - 1) + put_char!(buf, 1, c, '─'; fg = COL_BORDER) + put_char!(buf, nr, c, '─'; fg = COL_BORDER) + end + for r in 2:(nr - 1) + put_char!(buf, r, 1, '│'; fg = COL_BORDER) + put_char!(buf, r, nc, '│'; fg = COL_BORDER) + end + if nc >= length(FOOTER) + 6 + put_string!(buf, nr, 3, " " * FOOTER * " "; fg = COL_BORDER) + # House-style §3: the middot is the signature's smallest appearance — brass. + dot = findfirst('·', FOOTER) + dot === nothing || put_char!(buf, nr, 3 + 1 + (dot - 1), '·'; fg = COL_BRASS) + end + return buf +end + +# --- decoration helpers: write only into already-empty cells ------------------ +# These (trails) yield to anything already drawn, so +# they never land on top of prose or callouts. They are NOT what keeps the prose +# bodies / callouts / hull / border from colliding — that is z-order + scene +# composition (see `draw!`). These helpers just keep the *decorations* tidy. + +_put_if_empty!(buf, r, c, ch; fg) = + (inbounds(buf, r, c) && buf.chars[r, c] == ' ') ? put_char!(buf, r, c, ch; fg = fg) : buf + +# A short receding trail behind a moving body (opposite its velocity). Starts one +# cell OUTSIDE the radius (radius+2..radius+4), leaving a blank gap between the blob +# edge and the innermost dot so the debris reads as separate surface debris, not a +# prefix glued onto the body's prose ("··∘ drifting", never "··∘drifting"). Telegraphs +# motion in a still frame. Empty-cells-only. +function _draw_trail!(buf::CellBuffer, cx::Real, cy::Real, vx::Real, vy::Real, radius::Real; fg) + speed = hypot(vx, vy) + speed < 1e-3 && return buf + ux, uy = vx / speed, vy / speed + glyphs = ('∘', '·', '·') + for k in 1:3 + d = radius + 1 + k # +1 = one blank cell of gap from the blob edge + r = round(Int, cy - uy * d) # behind = opposite velocity + c = round(Int, cx - ux * d) + _put_if_empty!(buf, r, c, glyphs[k]; fg = fg) + end + return buf +end + +# --- closed callout box (single-sourced geometry) ---------------------------- + +_callout_stat(a) = Printf.@sprintf("d:%03dm v:%.2fµ", round(Int, a.radius * 10), hypot(a.vx, a.vy)) + +# Geometry of an asteroid's closed 3-row callout box centered on the body's x. It +# floats ABOVE the blob when there's room; if that would collide the top frame +# border (tall blob near the top), it FLIPS to below the blob instead. A connector +# (┬ above / ┴ below) plus a short leader joins the box to the blob. +function _callout_layout(a, pp) + stat = _callout_stat(a) + inner = length(stat) + 2 # one space pad each side + width = inner + 2 # plus the two │ borders + cx = round(Int, a.x) + left = cx - width ÷ 2 + r0 = round(Int, a.y) - pp.rows ÷ 2 + # Anchor to the ACTUAL prose content extent, not the pack's full bounding box: + # `pp.cells` entries are (row, col, char) (see `_blit_packed!`), so `c[1]` is the + # pack-local row. Short/sparse prose fills only part of `pp.rows`; anchoring to the + # bbox would float the box far from the visible glyphs. + rs = (c -> c[1]).(pp.cells) # pack-local rows that actually have prose + content_top = isempty(rs) ? r0 : r0 + minimum(rs) - 1 # screen row of the topmost prose cell + content_bot = isempty(rs) ? r0 : r0 + maximum(rs) - 1 # screen row of the bottommost prose cell + top_above = content_top - 4 # 3 box rows + 1 leader gap above the prose + if top_above >= 2 # fits above the top border (row 1 is border) + return (; stat, inner, width, cx, left, box_top = top_above, + place = :above, leader_from = top_above + 3, leader_to = content_top - 1) + else # flip below the prose + box_top = content_bot + 2 # 1 leader gap then the box + return (; stat, inner, width, cx, left, box_top, + place = :below, leader_from = content_bot + 1, leader_to = box_top - 1) + end +end + +function _draw_callout!(buf::CellBuffer, a, pp) + L = _callout_layout(a, pp) + conn = L.cx - L.left + 1 # 1-based offset of connector in the box rule + toprule = collect("┌" * ("─"^L.inner) * "┐") + botrule = collect("└" * ("─"^L.inner) * "┘") + # The connector tee points toward the blob: ┴ on the box edge nearest the blob. + if L.place == :above && 1 <= conn <= length(botrule) + botrule[conn] = '┴' # blob is below the box + elseif L.place == :below && 1 <= conn <= length(toprule) + toprule[conn] = '┴' # blob is above the box + end + mid = "│" * " " * L.stat * " " * "│" + put_string!(buf, L.box_top, L.left, String(toprule); fg = COL_TAG) + put_string!(buf, L.box_top + 1, L.left, mid; fg = COL_TAG) + put_string!(buf, L.box_top + 2, L.left, String(botrule); fg = COL_TAG) + for r in L.leader_from:L.leader_to # leader (only into empty cells) + _put_if_empty!(buf, r, L.cx, '│'; fg = COL_TAG) + end + return buf +end + +# --- bodies ------------------------------------------------------------------ + +function _blit_packed!(buf::CellBuffer, pp, cx::Real, cy::Real; fg, debug::Bool) + r0 = round(Int, cy) - pp.rows ÷ 2 + c0 = round(Int, cx) - pp.cols ÷ 2 + for (r, c, ch) in pp.cells + put_char!(buf, r0 + r - 1, c0 + c - 1, ch; fg = debug ? COL_DEBUG : fg) + end + return buf +end + +# Stippled silhouette rim: a faint dotted halo ONE cell OUTSIDE the pack's own +# rasterised mask, so the shaped-text body reads as a body and the prose is visibly +# hugging an edge (not floating as a paragraph). It is the SAME raster the prose +# packed into, dilated by one cell, so the dots ring exactly where the silhouette +# ends and the prose kisses it from inside — the measurement's "kiss the boundary" +# tell, made visible. Empty-cells-only, so it never lands on a glyph. +function _draw_rim!(buf::CellBuffer, pp, cx::Real, cy::Real; fg = COL_RIM) + R = pp.raster + nr, nc = size(R) + (nr < 2 || nc < 2) && return buf + r0 = round(Int, cy) - pp.rows ÷ 2 + c0 = round(Int, cx) - pp.cols ÷ 2 + filled(r, c) = 1 <= r <= nr && 1 <= c <= nc && @inbounds(R[r, c]) + @inbounds for r in 0:(nr + 1), c in 0:(nc + 1) + filled(r, c) && continue # halo is OUTSIDE the mask + (filled(r-1, c) || filled(r+1, c) || filled(r, c-1) || filled(r, c+1) || + filled(r-1, c-1) || filled(r-1, c+1) || filled(r+1, c-1) || filled(r+1, c+1)) || continue + _put_if_empty!(buf, r0 + r - 1, c0 + c - 1, '·'; fg = fg) + end + return buf +end + +# Rotate a silhouette's vertices by θ (radians) about the origin. θ=0 is an exact +# identity (cos 0 = 1, sin 0 = 0), so unrotated bodies and the golden are byte-stable. +_rotate_poly(poly, θ) = (c = cos(θ); s = sin(θ); + [GB.Point2{Float64}(c*p[1] - s*p[2], s*p[1] + c*p[2]) for p in poly]) + +_pack(body, θ = 0.0) = pack_prose_into(_rotate_poly(body.poly, θ), body.prep; + scale = max(4.0, 2 * body.radius), min_chord_width = 3.0) + +function _draw_asteroid!(buf::CellBuffer, a, debug::Bool) + pp = _pack(a, a.θ) + debug || _draw_rim!(buf, pp, a.x, a.y) # faint boundary under the prose + _blit_packed!(buf, pp, a.x, a.y; fg = COL_PROSE, debug = debug) + _draw_callout!(buf, a, pp) + return buf +end + +# Shards: warm-colored prose so the explosion pops as distinct from grey asteroids. +function _draw_shard!(buf::CellBuffer, sh, debug::Bool) + pp = _pack(sh) + debug || _draw_rim!(buf, pp, sh.x, sh.y) # each shard reads as its own broken piece + _blit_packed!(buf, pp, sh.x, sh.y; fg = COL_SHARD, debug = debug) + return buf +end + +# Ship: a single directional ARROW glyph at the ship cell, pointing along heading φ +# (8-way). While charging, the charge glyph sits one cell ahead along φ. Pure function +# of g. φ increases CLOCKWISE from up, so the table runs CW: N NE E SE S SW W NW. +const SHIP_ARROW = ('↑', '↗', '→', '↘', '↓', '↙', '←', '↖') # N NE E SE S SW W NW + +function _draw_ship!(buf::CellBuffer, g) + ship_visible(g) || return buf + s = g.ship + sx = round(Int, s.x); sy = round(Int, s.y) + arrow = SHIP_ARROW[mod(round(Int, s.φ / (π/4)), 8) + 1] + put_char!(buf, sy, sx, arrow; fg = COL_SHIP, bold = true) + if s.charge > 0 + dx, dy = sin(s.φ), -cos(s.φ) + put_char!(buf, round(Int, s.y + dy), round(Int, s.x + dx), + CHARGE_GLYPH[s.charge + 1]; fg = COL_BEAM, bold = true) # one cell ahead + end + return buf +end + +function _draw_projectiles!(buf::CellBuffer, g) + for p in g.projectiles + put_char!(buf, round(Int, p.y), round(Int, p.x), '•'; fg = COL_BEAM, bold = true) + end + return buf +end + +# --- compositor -------------------------------------------------------------- + +""" + draw!(buf, g) -> buf + +Paint the whole game into `buf` (cleared first). Pure: no terminal I/O. + +Non-overlap is guaranteed by **z-order + scene composition**, not by per-write +guards: the scene is hand-composed (and, for the golden, seed-pinned) so the prose +bodies and their callouts don't share cells, and the draw order layers later +elements over earlier ones deliberately. Layer order: motion trails (under) → +asteroid prose + closed callouts → shard prose → projectiles → ship → +border + footer. The decorations (trails) additionally yield +to already-occupied cells (`_put_if_empty!`) so they never land on the prose. +""" +function draw!(buf::CellBuffer, g::GameState) + clear!(buf) + for a in g.asteroids; _draw_trail!(buf, a.x, a.y, a.vx, a.vy, a.radius; fg = COL_TRAIL); end + for sh in g.shards; _draw_trail!(buf, sh.x, sh.y, sh.vx, sh.vy, sh.radius; fg = COL_TRAIL); end + for a in g.asteroids; _draw_asteroid!(buf, a, g.debug); end + for sh in g.shards; _draw_shard!(buf, sh, g.debug); end + _draw_projectiles!(buf, g) + _draw_ship!(buf, g) + _draw_border!(buf) + return buf +end diff --git a/examples/asteroid_tui/src/entities.jl b/examples/asteroid_tui/src/entities.jl new file mode 100644 index 0000000..7459d9d --- /dev/null +++ b/examples/asteroid_tui/src/entities.jl @@ -0,0 +1,40 @@ +# SPDX-License-Identifier: MIT +import GeometryBasics as GB +import TextMeasure + +const P2 = GB.Point2{Float64} + +mutable struct Ship + x::Float64; y::Float64 # cell coords (col, row), continuous + φ::Float64 # heading, radians (0 = up) + vx::Float64; vy::Float64 + charge::Int # 0..5 charge stage + alive::Bool + invuln::Int # remaining invulnerability ticks (>0 ⇒ blinking) +end + +mutable struct Asteroid + poly::Vector{P2} # unit-ish silhouette (Silhouettes frame) + x::Float64; y::Float64 # center in cell coords + vx::Float64; vy::Float64 + ω::Float64 # spin rad/tick + θ::Float64 # accumulated rotation + radius::Float64 # cell radius (for collision + scale) + prep::TextMeasure.Prepared # measured prose (measure once!) + age::Int # ticks since spawn (re-raster cadence) +end + +mutable struct Shard + poly::Vector{P2} + x::Float64; y::Float64 # center in cell coords + vx::Float64; vy::Float64 + prep::TextMeasure.Prepared # subprep slice — NOT re-measured + ttl::Int # ticks to live + radius::Float64 +end + +mutable struct Projectile + x::Float64; y::Float64 + vx::Float64; vy::Float64 + ttl::Int # ticks to live (range backstop; also despawns off-screen) +end diff --git a/examples/asteroid_tui/src/game.jl b/examples/asteroid_tui/src/game.jl new file mode 100644 index 0000000..e0c1d00 --- /dev/null +++ b/examples/asteroid_tui/src/game.jl @@ -0,0 +1,372 @@ +# SPDX-License-Identifier: MIT +using Random: AbstractRNG, Xoshiro +import TextMeasure +using TextMeasure: subprep +using Silhouettes: asteroid_polygon, voronoi_shatter +import GeometryBasics as GB + +mutable struct GameState + width::Int; height::Int # buffer cols, rows + ship::Ship + asteroids::Vector{Asteroid} + shards::Vector{Shard} + projectiles::Vector{Projectile} + rng::Xoshiro + tick_count::Int + debug::Bool + prev_fire::Bool # for release-to-launch edge detection + prev_debug::Bool # edge-triggered debug toggle + respawn_in::Int # ticks until ship respawns (when dead) + last_hit_glyphs::Vector{String} # words of the most recently fractured asteroid (for tests) + n_target::Int # replenish target (starting n_asteroids) +end + +const CHARGE_MAX = 5 +const INVULN_TICKS = 120 # ~2s; (120÷10)%2==0 ⇒ ship_visible at tick 0 +const MOVE_ACCEL = 0.18 # per-axis velocity added per held strafe key +const FRICTION = 0.90 # light friction; crisp strafe stop +const SHATTER_CLOSING = 0.13 # asteroid closing-speed: ≥ ⇒ fracture, < ⇒ bounce. + # Scaled with the _spawn_asteroid velocity coefficient + # (0.2) so the bounce/shatter MIX is speed-independent: + # halve both to slow the field without changing which + # collisions shatter. +const PROJECTILE_SPEED = 1.2 # cells/tick — fast vs the slow asteroid field +const PROJECTILE_TTL = 90 # lifetime backstop (off-screen despawn usually fires first) +const BURST_SPREAD = 0.22 # rad (~12.5°) half-fan for a charged burst + +_wrap(v, hi) = mod(v, hi) + +# A body is fully off-screen once its centre passes an edge by more than its radius. +_offscreen(b, w, h) = b.x < -b.radius || b.x > w + b.radius || b.y < -b.radius || b.y > h + b.radius + +# Visual-space heading from ship (sx,sy) toward cursor (cx,cy). Cells are ~2:1, so +# the row delta is multiplied by 2 (cell aspect) before atan, so the nose points at +# the cursor ON SCREEN. up=0, clockwise (matches dir(φ)=(sin φ, -cos φ)). +aim_heading(sx, sy, cx, cy) = atan(cx - sx, -((cy - sy) * 2.0)) + +function _spawn_asteroid(rng::AbstractRNG, width, height) + poly = asteroid_polygon(rng; n = rand(rng, 8:16), lumpiness = 0.2 + 0.5 * rand(rng)) + prep = TextMeasure.prepare(CellBackend(), asteroid_prose(rng)) + radius = 6.0 + 6.0 * rand(rng) + return Asteroid(poly, + rand(rng) * width, rand(rng) * height, + (rand(rng) - 0.5) * 0.2, (rand(rng) - 0.5) * 0.2, # ~7× slower than 1.4; see SHATTER_CLOSING + (rand(rng) - 0.5) * 0.08, # ω in rad/tick (~±1.2 rad/s max at 30fps — a visible tumble) + 0.0, radius, prep, 0) +end + +""" + new_game(rng; width=120, height=40, n_asteroids=5) -> GameState + +Seeded initial state. All randomness flows from `rng` (pass `Xoshiro(seed)` for a +reproducible game — the golden test relies on this). +""" +function new_game(rng::Xoshiro = Xoshiro(0); width=120, height=40, n_asteroids=5) + ship = Ship(width/2, height/2, 0.0, 0.0, 0.0, 0, true, INVULN_TICKS) + asteroids = [_spawn_asteroid(rng, width, height) for _ in 1:n_asteroids] + return GameState(width, height, ship, asteroids, Shard[], Projectile[], rng, 0, false, + false, false, 0, String[], n_asteroids) +end + +# --- physics ----------------------------------------------------------------- + +function _advance_ship!(g::GameState, in::Input) + s = g.ship + s.alive || return + in.aim !== nothing && (s.φ = aim_heading(s.x, s.y, in.aim[1], in.aim[2])) + ax = (in.right ? 1.0 : 0.0) - (in.left ? 1.0 : 0.0) + ay = (in.down ? 1.0 : 0.0) - (in.up ? 1.0 : 0.0) + if ax != 0.0 || ay != 0.0 + inv = 1.0 / hypot(ax, ay) # normalise diagonals + s.vx += MOVE_ACCEL * ax * inv + s.vy += MOVE_ACCEL * ay * inv + end + s.vx *= FRICTION; s.vy *= FRICTION + s.x = _wrap(s.x + s.vx, g.width); s.y = _wrap(s.y + s.vy, g.height) + s.invuln > 0 && (s.invuln -= 1) +end + +function _advance_asteroids!(g::GameState) + for a in g.asteroids + a.x += a.vx; a.y += a.vy # no wrap — only the ship wraps + a.θ += a.ω; a.age += 1 + end + filter!(a -> !_offscreen(a, g.width, g.height), g.asteroids) + for sh in g.shards + sh.x += sh.vx; sh.y += sh.vy + sh.ttl -= 1 + end + filter!(sh -> sh.ttl > 0 && !_offscreen(sh, g.width, g.height), g.shards) +end + +function _fire_burst!(g::GameState, n::Int) + s = g.ship + ox, oy = s.x + sin(s.φ), s.y - cos(s.φ) # nose + for k in 1:n + off = n == 1 ? 0.0 : -BURST_SPREAD + 2*BURST_SPREAD*(k-1)/(n-1) # even fan + a = s.φ + off + push!(g.projectiles, Projectile(ox, oy, + PROJECTILE_SPEED * sin(a), + -PROJECTILE_SPEED * cos(a), + PROJECTILE_TTL)) + end + return g +end + +# Hold `fire` to grow the charge; the first release fires a fan burst of (1 + charge) +# projectiles from the nose. No RNG — deterministic. +function _handle_charge_and_fire!(g::GameState, in::Input) + s = g.ship + if s.alive + if in.fire + s.charge = min(s.charge + 1, CHARGE_MAX) + elseif g.prev_fire && s.charge > 0 + _fire_burst!(g, 1 + s.charge) + s.charge = 0 + end + end + g.prev_fire = in.fire + return g +end + +# --- death / respawn --------------------------------------------------------- + +""" + kill_ship!(g) + +Destroy the ship; schedules a respawn after a short timer. +""" +function kill_ship!(g::GameState) + g.ship.alive = false + g.respawn_in = 60 # ~1s before respawn + return g +end + +function _handle_respawn!(g::GameState) + g.ship.alive && return g + g.respawn_in -= 1 + if g.respawn_in <= 0 + g.ship = Ship(g.width/2, g.height/2, 0.0, 0.0, 0.0, 0, true, INVULN_TICKS) + end + return g +end + +# blink at ~3Hz: visible 10 ticks on / 10 off while invulnerable +ship_visible(g::GameState) = g.ship.alive && (g.ship.invuln == 0 || (g.ship.invuln ÷ 10) % 2 == 0) + +# --- fracture ---------------------------------------------------------------- + +# Split a Prepared's segment range into up to `n` contiguous chunks at :word +# boundaries, so concatenating the chunks' words reproduces the original word order +# exactly. Guarantees (for n ≤ word-count): returns exactly `n` ranges, none empty, +# tiling 1:length(segments) with no gaps/overlaps. +function _word_boundary_splits(prep::TextMeasure.Prepared, n::Int) + word_idx = [i for (i, s) in enumerate(prep.segments) if s.kind === :word] + nw = length(word_idx) + nseg = length(prep.segments) + (nw == 0 || n <= 1) && return UnitRange{Int}[1:nseg] + n = clamp(n, 1, nw) + # Group nw words into n contiguous nonempty groups: chunk i (1-based) covers word + # ordinals div((i-1)*nw, n)+1 .. div(i*nw, n). Because n ≤ nw, every group has + # ≥ 1 word and the first-word ordinal strictly increases, so no empty/duplicate + # boundary can occur. Map each group to a segment range that tiles 1:nseg — + # chunk 1 starts at segment 1; chunk i>1 starts at its first word's segment; the + # last chunk ends at nseg (trailing whitespace stays attached to the final chunk). + starts = Int[1] # chunk 1 starts at segment 1 + for i in 2:n + lo = div((i - 1) * nw, n) + 1 # first word ordinal of chunk i (≥ 2) + push!(starts, word_idx[lo]) # ⇒ segment index ≥ 3 > 1, strictly increasing + end + ranges = UnitRange{Int}[] + for (j, st) in enumerate(starts) + stop = j < length(starts) ? starts[j + 1] - 1 : nseg + push!(ranges, st:stop) + end + return ranges +end + +""" + fracture_asteroid!(g, idx, impact) + +Remove asteroid `idx`, fracture its silhouette with `voronoi_shatter` seeded at +`impact` — a CELL-space offset from the asteroid centre, converted to the polygon's +own (~±1) frame internally — and re-pack each shard with a `subprep` slice of the +asteroid's already-measured prose (no re-measurement). The slices tile the segment +range, so every glyph survives in exactly one shard, in original order. +""" +function fracture_asteroid!(g::GameState, idx::Int, impact::GB.Point2{Float64}) + a = g.asteroids[idx] + nword = count(s -> s.kind === :word, a.prep.segments) + n_shards = 2 + (nword >= 6 ? 2 : 0) + # Convert the CELL-space contact offset into the polygon's own (~±1) frame and + # clamp into the polygon bbox so voronoi_shatter's seeds land inside the parent. + fx = clamp(impact[1] / a.radius, minimum(p[1] for p in a.poly), maximum(p[1] for p in a.poly)) + fy = clamp(impact[2] / a.radius, minimum(p[2] for p in a.poly), maximum(p[2] for p in a.poly)) + polys = voronoi_shatter(a.poly, GB.Point2{Float64}(fx, fy); n_shards = n_shards) + isempty(polys) && (polys = [a.poly]) + # MAJOR #3: shatter may return fewer polys than requested (length ≤ n_shards), + # so split into EXACTLY length(polys) chunks and assert the 1:1 pairing before + # zip — otherwise zip would silently truncate and drop glyphs. + ranges = _word_boundary_splits(a.prep, length(polys)) + # If we have more polys than words, drop the wordless extra polys (keep the + # text-bearing ones) so the pairing stays 1:1 and lossless. + if length(polys) > length(ranges) + polys = polys[1:length(ranges)] + end + @assert length(ranges) == length(polys) "fracture: $(length(ranges)) ranges vs $(length(polys)) polys" + deleteat!(g.asteroids, idx) + g.last_hit_glyphs = [s.str for s in a.prep.segments if s.kind === :word] + for (poly, r) in zip(polys, ranges) + sp = subprep(a.prep, r) + # Scatter shards like an explosion: spawn each at the asteroid center offset + # toward its own polygon centroid (so it leaves from where it sat in the + # parent) and give it an OUTWARD velocity along that direction. Without this, + # all shards spawn at one point and their prose piles into an illegible blob. + cx = sum(p[1] for p in poly) / length(poly) + cy = sum(p[2] for p in poly) / length(poly) + d = hypot(cx, cy) + ux, uy = d > 1e-9 ? (cx / d, cy / d) : (0.0, 0.0) # unit outward direction + spread = a.radius * 0.9 # spawn offset in cells + scatter = 0.6 # outward speed (cells/tick) + push!(g.shards, Shard(poly, + a.x + ux * spread, a.y + uy * spread, + a.vx + ux * scatter + (rand(g.rng) - 0.5) * 0.2, + a.vy + uy * scatter + (rand(g.rng) - 0.5) * 0.2, + sp, 90, a.radius / 2)) + end + return g +end + +# Asteroid↔asteroid: bounce below the closing-speed threshold, fracture both above. +# Euclidean — only the ship wraps. Collect fracture pairs and apply them AFTER the +# sweep so deleteat! never invalidates a live index mid-iteration. +function _resolve_asteroid_collisions!(g::GameState) + n = length(g.asteroids) + n < 2 && return g + to_fracture = Tuple{Int,Float64,Float64}[] + fractured = falses(n) + # Single-pass O(n²) sweep. The bounce branch mutates a's position/velocity in + # place, and a's corrected state is intentionally reused for a's remaining j + # checks this tick. In clusters of 3+ overlapping asteroids one pass may leave + # residual overlap (resolved on the next tick) — acceptable for a demo. + for i in 1:(n-1) + fractured[i] && continue + a = g.asteroids[i] + for j in (i+1):n + fractured[j] && continue + b = g.asteroids[j] + dx = b.x - a.x; dy = b.y - a.y; dist = hypot(dx, dy) + rsum = a.radius + b.radius + (dist >= rsum || dist < 1e-9) && continue + nx, ny = dx/dist, dy/dist # contact normal a→b + rvx, rvy = b.vx - a.vx, b.vy - a.vy + closing = -(rvx*nx + rvy*ny) # >0 ⇒ approaching + if closing >= SHATTER_CLOSING + push!(to_fracture, (i, nx*a.radius, ny*a.radius)) # cell-space contact offsets + push!(to_fracture, (j, -nx*b.radius, -ny*b.radius)) + fractured[i] = true; fractured[j] = true + break + else + p = rvx*nx + rvy*ny # elastic, equal-mass reflection + a.vx += p*nx; a.vy += p*ny + b.vx -= p*nx; b.vy -= p*ny + overlap = (rsum - dist)/2 + 0.01 # push the pair apart (no wrap) + a.x = clamp(a.x - nx*overlap, -a.radius, g.width + a.radius) + a.y = clamp(a.y - ny*overlap, -a.radius, g.height + a.radius) + b.x = clamp(b.x + nx*overlap, -b.radius, g.width + b.radius) + b.y = clamp(b.y + ny*overlap, -b.radius, g.height + b.radius) + end + end + end + for (idx, idx_dx, idx_dy) in sort(to_fracture; by = first, rev = true) + idx <= length(g.asteroids) || continue + fracture_asteroid!(g, idx, GB.Point2{Float64}(idx_dx, idx_dy)) + end + return g +end + +# Ship↔asteroid: alive && not invulnerable && Euclidean distance within the +# asteroid's radius ⇒ kill_ship!. Only the ship wraps. The asteroid is never +# mutated (it continues). +function _resolve_ship_collision!(g::GameState) + s = g.ship + (s.alive && s.invuln == 0) || return g + for a in g.asteroids + dist = hypot(a.x - s.x, a.y - s.y) + if dist <= a.radius + kill_ship!(g) + return g + end + end + return g +end + +function _advance_projectiles!(g::GameState) + for p in g.projectiles + p.x += p.vx; p.y += p.vy; p.ttl -= 1 + end + filter!(p -> p.ttl > 0 && 0 <= p.x <= g.width && 0 <= p.y <= g.height, g.projectiles) + return g +end + +# Each projectile fractures the first asteroid whose radius it's inside (Euclidean), +# then is consumed. Asteroids fracture one at a time; we re-read g.asteroids per +# projectile (fracture_asteroid! deleteat!s), and remove spent projectiles after. +function _resolve_projectile_collisions!(g::GameState) + isempty(g.projectiles) && return g + spent = falses(length(g.projectiles)) + for (pidx, p) in enumerate(g.projectiles) + for idx in eachindex(g.asteroids) + a = g.asteroids[idx] + if hypot(a.x - p.x, a.y - p.y) <= a.radius + fracture_asteroid!(g, idx, GB.Point2{Float64}(p.x - a.x, p.y - a.y)) + spent[pidx] = true + break + end + end + end + deleteat!(g.projectiles, findall(spent)) + return g +end + +# --- tick -------------------------------------------------------------------- + +# When the live count drops below the target, spawn ONE asteroid at a screen edge +# from a FIXED number of g.rng draws (no rejection loop) so the RNG stream stays +# predictable. `g.n_target` is the starting n_asteroids (set in new_game). +function _replenish_field!(g::GameState) + length(g.asteroids) >= g.n_target && return g + a = _spawn_asteroid(g.rng, g.width, g.height) # x,y,v overwritten below; its draws are kept so the rng stream stays stable + edge = rand(g.rng, 1:4); t = rand(g.rng) # fixed 2 extra draws + sp = hypot(a.vx, a.vy) # reuse the drawn speed, point it inward + if edge == 1; a.x = t*g.width; a.y = 0.0; a.vx = 0.0; a.vy = sp # top → down + elseif edge == 2; a.x = t*g.width; a.y = g.height; a.vx = 0.0; a.vy = -sp # bot → up + elseif edge == 3; a.x = 0.0; a.y = t*g.height; a.vx = sp; a.vy = 0.0 # left → right + else a.x = g.width; a.y = t*g.height; a.vx = -sp; a.vy = 0.0 # right→ left + end + push!(g.asteroids, a) + return g +end + +""" + tick!(g, input) -> g + +Advance the game one frame. Pure w.r.t. the terminal — mutates only `g`. Uses only +`g.rng` for any randomness (respawns), so a seeded `new_game` + scripted inputs is +fully reproducible. +""" +function tick!(g::GameState, in::Input) + _handle_respawn!(g) + in.debug && !g.prev_debug && (g.debug = !g.debug) # toggle once per press + g.prev_debug = in.debug + _advance_ship!(g, in) + _advance_asteroids!(g) + _handle_charge_and_fire!(g, in) + _advance_projectiles!(g) + _resolve_projectile_collisions!(g) # bullets → asteroid + _resolve_asteroid_collisions!(g) # asteroid ↔ asteroid + _resolve_ship_collision!(g) # ship ↔ asteroid (death) + _replenish_field!(g) # top up toward g.n_target (one per tick) + g.tick_count += 1 + return g +end diff --git a/examples/asteroid_tui/src/input.jl b/examples/asteroid_tui/src/input.jl new file mode 100644 index 0000000..d61391d --- /dev/null +++ b/examples/asteroid_tui/src/input.jl @@ -0,0 +1,39 @@ +# SPDX-License-Identifier: MIT +""" + Input(; up=false, down=false, left=false, right=false, fire=false, + aim=nothing, debug=false, quit=false) + +One tick's intent, decoupled from key encoding (twin-stick). + * `up`/`down`/`left`/`right` are **strafe** flags — direct-velocity movement; `left`/`right` + strafe, they do NOT turn. + * `fire` held grows the charge; the first `fire=false` after `fire=true` launches. + * `aim` is the **cursor cell** `(cx, cy)` or `nothing`. The sim turns it into a heading from the + live ship position; `nothing` ⇒ leave φ unchanged (headless paths stay reproducible). +All fields keyword-only. +""" +Base.@kwdef struct Input + up::Bool = false + down::Bool = false + left::Bool = false + right::Bool = false + fire::Bool = false + aim::Union{Nothing,Tuple{Float64,Float64}} = nothing + debug::Bool = false + quit::Bool = false +end + +""" + ScriptedInput(seq::Vector{Input}) + +Deterministic input source for the headless golden test. `next_input!` returns the +next entry, repeating the last once exhausted. +""" +mutable struct ScriptedInput + seq::Vector{Input} + i::Int +end +ScriptedInput(seq::Vector{Input}) = ScriptedInput(seq, 0) +function next_input!(s::ScriptedInput) + s.i = min(s.i + 1, length(s.seq)) + return isempty(s.seq) ? Input() : s.seq[s.i] +end diff --git a/examples/asteroid_tui/src/pack.jl b/examples/asteroid_tui/src/pack.jl new file mode 100644 index 0000000..ba4ef9c --- /dev/null +++ b/examples/asteroid_tui/src/pack.jl @@ -0,0 +1,48 @@ +# SPDX-License-Identifier: MIT +import TextMeasure +using TextMeasureLayouts: shape_pack, raster_chord_fn +using Silhouettes: rasterize +import GeometryBasics as GB + +""" + PackedProse(rows, cols, cells) + +A silhouette's packed interior text in **local cell coordinates** (row 1 = top of +the silhouette's bounding box). `cells :: Vector{Tuple{Int,Int,Char}}` is +`(row, col, char)` for every glyph, in reading order. +""" +struct PackedProse + rows :: Int + cols :: Int + cells :: Vector{Tuple{Int,Int,Char}} + raster :: BitMatrix # the silhouette mask the prose packs into (row 1 = top) +end + +""" + pack_prose_into(polygon, prep; scale, min_chord_width=3.0) -> PackedProse + +Rasterize `polygon` (scaled to `scale` cells across its larger extent) to a cell +grid, then `shape_pack` the `:word` segments of `prep` (built with `CellBackend`) +into the silhouette at one row per line. Coordinates are integer cells. +""" +function pack_prose_into(polygon::Vector{GB.Point2{Float64}}, prep::TextMeasure.Prepared; + scale::Real, min_chord_width::Real=3.0) + xs = [p[1] for p in polygon]; ys = [p[2] for p in polygon] + span = max(maximum(xs) - minimum(xs), maximum(ys) - minimum(ys)) + span <= 0 && return PackedProse(1, 1, Tuple{Int,Int,Char}[], falses(1, 1)) + cell = span / scale # polygon-units per cell + raster = rasterize(polygon, cell) # BitMatrix, row 1 = top + cf = raster_chord_fn(raster, 1.0) # work in cell units (cell_size 1) + pk = shape_pack(prep, cf; line_advance = 1.0, min_chord_width = Float64(min_chord_width)) + cells = Tuple{Int,Int,Char}[] + for pl in pk.placements + seg = prep.segments[pl.segment_index] + seg.kind === :word || continue + row = round(Int, pl.y) # baseline y = band (ascent==1) + col0 = round(Int, pl.x) + 1 # left edge → 1-based col + for (k, ch) in enumerate(seg.str) + push!(cells, (row, col0 + k - 1, ch)) + end + end + return PackedProse(size(raster, 1), size(raster, 2), cells, raster) +end diff --git a/examples/asteroid_tui/src/prose.jl b/examples/asteroid_tui/src/prose.jl new file mode 100644 index 0000000..49df3d7 --- /dev/null +++ b/examples/asteroid_tui/src/prose.jl @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MIT +using Random: AbstractRNG + +const _CLASS = ("C-type", "S-type", "M-type", "carbonaceous", "silicate", + "metallic", "chondritic", "basaltic") +const _MATERIAL = ("iron-nickel", "olivine", "pyroxene", "regolith", "magnetite", + "ice-laced rock", "porous dust", "shock-veined ore") +const _TEMPER = ("ancient and cold", "scarred by impacts", "tumbling lazily", + "newly calved", "radar-bright", "spectrally dark") +const _CALLPREFIX = ("NX", "VG", "KR", "ZL", "QF", "BD") + +# Conservative count: class × material × temper (callsign/spin add far more). +PROSE_VARIANTS() = length(_CLASS) * length(_MATERIAL) * length(_TEMPER) + +""" + asteroid_prose(rng) -> String + +Deterministic-by-`rng` descriptive sentence for an asteroid's interior text. +Pulls only from `rng` (no global RNG access). +""" +function asteroid_prose(rng::AbstractRNG) + cls = rand(rng, _CLASS) + mat = rand(rng, _MATERIAL) + tmp = rand(rng, _TEMPER) + call = string(rand(rng, _CALLPREFIX), '-', lpad(rand(rng, 100:999), 3, '0')) + spin = round(rand(rng) * 0.4; digits = 2) + return "$cls drifter $call composed of $mat, $tmp, spinning at $spin rad per second." +end diff --git a/examples/asteroid_tui/src/render_tachikoma.jl b/examples/asteroid_tui/src/render_tachikoma.jl new file mode 100644 index 0000000..689d3ed --- /dev/null +++ b/examples/asteroid_tui/src/render_tachikoma.jl @@ -0,0 +1,249 @@ +# SPDX-License-Identifier: MIT +# +# Interactive Tachikoma renderer + the shared game loop. +# +# The loop itself (`game_loop!`) is terminal-agnostic: it takes `poll` / `present` / +# `pace` callbacks, so the EXACT SAME loop code runs both the interactive game +# (`run_game` on a real TTY) and the headless smoke test. `run_game`'s headless branch +# builds the Tachikoma `Terminal` over an injectable `IOBuffer` (explicit size, no TTY +# query), so the smoke test drives the REAL entry point — boot → loop → tick → draw → +# `_present` (`draw!` → drain → flush) → drain — and asserts bytes were flushed +# (`position(io) > 0`). Input dispatch (`Input` → `tick!`), `draw!` into a `CellBuffer`, +# `drain_to_tachikoma!`, AND `_present` are all exercised without a TTY. +# +# What STILL genuinely requires a live TTY (human tier-2/3 only, not unit-testable): +# - `_poll_input!` raw-mode keypress + mouse capture (reading actual events) +# - the visible on-screen render and real ≥30fps wall-clock pacing +# - the *visible* respawn blink / `?` debug overlay +# +# Twin-stick controls: WASD/arrows STRAFE the ship (direct velocity, no turning), the +# mouse aims (the nose points at the cursor), LMB or Space charges (release to fire). +# Held keys are tracked with a short decay window (key-repeat driven), so motion stays +# smooth across frames without per-key release events. +import Tachikoma as TK + +# Drain a renderer-agnostic CellBuffer into a Tachikoma Buffer (1-based x=col, y=row). +# No TTY needed — a Tachikoma Buffer is an in-memory cell grid; this is smoke-tested. +# +# Every write is guarded by `TK.in_bounds`: a CellBuffer may be larger than the +# Tachikoma buffer it's drained into (e.g. a resized terminal), so out-of-range cells +# are skipped rather than throwing. Without this guard `set_char!` raises a +# `BoundsError` and crashes the render loop — caught by the headless game-loop smoke +# test (test_gameloop.jl), which is exactly what that test exists to prevent. +function drain_to_tachikoma!(tbuf, cb::CellBuffer) + for r in 1:nrows(cb), c in 1:ncols(cb) + ch = cb.chars[r, c] + (ch == ' ' && cb.fg[r, c] == 0x00) && continue + TK.in_bounds(tbuf, c, r) || continue + style = TK.Style(; fg = TK.Color256(Int(cb.fg[r, c])), bold = cb.bold[r, c]) + TK.set_char!(tbuf, c, r, ch, style) + end + return tbuf +end + +""" + step_frame!(g, cb, input) -> cb + +One frame of game logic: advance the simulation by `input` and repaint `cb`. Pure +w.r.t. the terminal (no I/O). This is the unit the loop repeats. +""" +function step_frame!(g::GameState, cb::CellBuffer, input::Input) + tick!(g, input) + draw!(cb, g) + return cb +end + +""" + game_loop!(g, cb; poll, present, pace, max_frames=nothing) -> Int + +The shared fixed-timestep loop, terminal-agnostic. Each frame: `poll(frame)::Input` +→ quit if `input.quit` → `step_frame!` → `present(cb, frame)` → `pace(frame)`. +Returns the number of frames run. Both `run_game` (real TTY) and the headless smoke +test drive THIS function; only the callbacks differ, so the loop logic itself is +covered by tests. +""" +function game_loop!(g::GameState, cb::CellBuffer; poll, present, pace, max_frames = nothing) + frame = 0 + while true + input = poll(frame) + input.quit && break + step_frame!(g, cb, input) + present(cb, frame) + pace(frame) + frame += 1 + max_frames !== nothing && frame >= max_frames && break + end + return frame +end + +""" + run_game(; width=120, height=40, seed=0, max_frames=nothing, + io=nothing, interactive=(io===nothing && stdout isa Base.TTY)) + +Top-level entry point — the exact code `run.jl` boots. Builds a game + `CellBuffer`, +constructs a Tachikoma `Terminal`, and drives [`game_loop!`](@ref) with +terminal-backed callbacks: poll keys → `tick!` → `draw!` → drain → present. + +Two modes, selected by `interactive`: + + * **Interactive** (a real TTY): enters the alternate screen + raw mode via + `enter_tui!`, enables any-motion mouse reporting, polls real key + mouse events each + frame into a stateful `InputState` (held-key decay + cursor + LMB), presents at + ~30fps, and ALWAYS restores the terminal (`leave_tui!`) on quit *or* error. + Linux/macOS (ANSI). + * **Headless** (no TTY, or an explicit `io`): builds the `Terminal` over an + `IOBuffer` (or the supplied `io`) with an explicit `size`, so it never queries a + TTY. It takes no keys (`Input()` each frame), runs the *real* present path + (`draw!` → drain → flush to `io`), and skips wall-clock pacing. This is the path + the headless smoke test drives, so the actual entry point + render path are + exercised without a terminal. + +`max_frames` bounds the loop (used by the smoke test and any scripted run). + +Key map (interactive): `wasd` / arrows STRAFE (the mouse aims the ship), LMB or space +charges (release to fire), `?` toggles the debug overlay, `q` / `Esc` / `Ctrl-C` quit. +""" +function run_game(; width = 120, height = 40, seed = 0, max_frames = nothing, + io::Union{IO,Nothing} = nothing, + interactive::Bool = (io === nothing && stdout isa Base.TTY)) + g = new_game(Xoshiro(seed); width = width, height = height) + cb = CellBuffer(height, width) + + if interactive + # `size = (cols, rows)` is the correct kwarg shape (a NamedTuple): the + # `Terminal(; size)` constructor reads `size.cols`/`size.rows`. Passing a + # `Rect` here throws `FieldError: Rect has no field cols` — the original bug. + term = TK.Terminal(; size = (cols = width, rows = height)) + state = InputState() + TK.enter_tui!(term) # alt-screen + raw mode + start_input! + print(term.io, "\e[?1003h"); flush(term.io) # any-motion mouse: enter_tui! enables 1000h+1002h+1006h, not 1003h bare-motion + try + _run_loop!(g, cb, term; max_frames = max_frames, + poll = frame -> _poll_input!(state, term, frame), + pace = frame -> sleep(1 / 30)) # ~30fps + finally + print(term.io, "\e[?1003l"); flush(term.io) # restore bare-motion mouse + TK.leave_tui!(term) # restore screen + raw mode on quit OR error + end + else + sink = io === nothing ? IOBuffer() : io + term = TK.Terminal(; io = sink, size = (cols = width, rows = height)) + _run_loop!(g, cb, term; max_frames = max_frames, + poll = frame -> Input(), # no keys without a TTY + pace = frame -> nothing) # no wall-clock pacing + end + return g +end + +# Wire the terminal-backed callbacks and run the shared loop. `poll`/`pace` differ by +# mode; `present` is the REAL render path in both, so it is covered by the headless +# smoke test (which drives `run_game` over an IOBuffer terminal). +function _run_loop!(g::GameState, cb::CellBuffer, term; max_frames, poll, pace) + present = (buf, frame) -> _present(term, buf) + return game_loop!(g, cb; poll = poll, present = present, pace = pace, + max_frames = max_frames) +end + +# --- input: stateful twin-stick over the real Tachikoma 2.1.0 API --------------- +# The PURE logic (sweep_stale!, fold_input) needs no terminal and is headless-tested; +# _poll_input! needs a live TK.poll_event and is the human tier-2/3 check. + +""" + InputState() + +Per-`run_game` mutable input state threaded across frames: `held` is +`(key,char) => last-seen frame`, `cursor` the last mouse cell (`nothing` until the +first event), `lmb_down` the edge-derived left-button state. Holds no spatial bounds — +the cursor is already terminal-bounded by the mouse events that set it. +""" +mutable struct InputState + held::Dict{Tuple{Symbol,Char},Int} + cursor::Union{Nothing,Tuple{Int,Int}} + lmb_down::Bool +end +InputState() = InputState(Dict{Tuple{Symbol,Char},Int}(), nothing, false) + +# Decay window in loop frames (~33ms each at sleep(1/30)). Must exceed the worst-case +# inter-repeat interval on the slowest non-kitty autorepeat; tuned at the terminal. +const DECAY_WINDOW = 5 + +"Evict held keys whose last-seen frame is older than `window` before `now`. Mutates & returns." +function sweep_stale!(held::Dict{Tuple{Symbol,Char},Int}, now::Int, window::Int) + for (id, last) in held + now - last > window && delete!(held, id) + end + return held +end + +_char_held(held, chars) = any(id -> id[1] === :char && id[2] in chars, keys(held)) + +""" + fold_input(state, now) -> Input + +Pure: fold the held-key map + cursor + fire into one `Input`. WASD/arrows → strafe; +`aim` is the raw cursor cell (the sim computes φ from the live ship position); +`fire = lmb_down || Space-held`. +""" +function fold_input(state::InputState, now::Int) + held = state.held + up = _char_held(held, ('w','W')) || haskey(held, (:up,'\0')) + down = _char_held(held, ('s','S')) || haskey(held, (:down,'\0')) + left = _char_held(held, ('a','A')) || haskey(held, (:left,'\0')) + right = _char_held(held, ('d','D')) || haskey(held, (:right,'\0')) + # A held `?` lingers up to DECAY_WINDOW frames, so a re-tap inside that window won't + # toggle twice — tick!'s edge-latch still sees debug=true; rapid double-taps coalesce. + debug = _char_held(held, ('?',)) + quit = _char_held(held, ('q','Q')) || haskey(held, (:escape,'\0')) || haskey(held, (:ctrl_c,'\0')) + fire = state.lmb_down || haskey(held, (:char,' ')) + aim = state.cursor === nothing ? nothing : + (Float64(state.cursor[1]), Float64(state.cursor[2])) + return Input(up=up, down=down, left=left, right=right, fire=fire, aim=aim, + debug=debug, quit=quit) +end + +""" + _poll_input!(state, term, frame) -> Input + +Drain events into `state`, decay stale keys, fold to one `Input`. Stamp `frame` on +the held map for `key_press`/`key_repeat`, delete on `key_release`; update cursor on +`mouse_move`/`mouse_drag`; `lmb_down` edge-derived from `mouse_left` press/drag→true, +release→false. Needs live `TK.poll_event` (tier-2/3). +""" +function _poll_input!(state::InputState, term, frame::Int)::Input + while true + evt = TK.poll_event(0.0005) + evt === nothing && break + if evt isa TK.KeyEvent + id = (evt.key, evt.char) + if evt.action == TK.key_press || evt.action == TK.key_repeat + state.held[id] = frame + elseif evt.action == TK.key_release + delete!(state.held, id) + end + elseif evt isa TK.MouseEvent + (evt.action == TK.mouse_move || evt.action == TK.mouse_drag) && (state.cursor = (evt.x, evt.y)) + if evt.button == TK.mouse_left + evt.action in (TK.mouse_press, TK.mouse_drag) && (state.lmb_down = true) + evt.action == TK.mouse_release && (state.lmb_down = false) + end + end + end + sweep_stale!(state.held, frame, DECAY_WINDOW) + return fold_input(state, frame) +end + +""" + _present(term, cb) + +Blit the renderer-agnostic `CellBuffer` to the terminal for one frame. `draw!` hands +us the back buffer (holding a stale frame), so we `reset!` it, drain the `CellBuffer` +in, and let `draw!` diff against the front buffer and emit only the changed cells — +then flush to `term.io`. Works on a real TTY and on a headless `IOBuffer` alike. +""" +function _present(term, cb::CellBuffer) + TK.draw!(term) do f + TK.reset!(f.buffer) + drain_to_tachikoma!(f.buffer, cb) + end + return nothing +end diff --git a/examples/asteroid_tui/test/golden/frame60.sha256 b/examples/asteroid_tui/test/golden/frame60.sha256 new file mode 100644 index 0000000..837ca2f --- /dev/null +++ b/examples/asteroid_tui/test/golden/frame60.sha256 @@ -0,0 +1 @@ +54d1aaab64329b9d7238be0166b6444f71e267b97770db3db52cb244f67d3d18 \ No newline at end of file diff --git a/examples/asteroid_tui/test/golden/frame60.txt b/examples/asteroid_tui/test/golden/frame60.txt new file mode 100644 index 0000000..0978104 --- /dev/null +++ b/examples/asteroid_tui/test/golden/frame60.txt @@ -0,0 +1,36 @@ +┌──────────────────────────────────────────────────────────────────────────────────────────────────────────────────┐ +│ ┌────────────────┐ │ +│ │ d:060m v:0.19µ │ │ +│ ┌────────────────┐ └────────┴───────┘ │ +│ │ d:100m v:0.24µ │ ····· │ +│ └────────┴───────┘ ··Porous, │ +│ ·········· ··fast, ·· │ +│ ··Born ··· ·and ·· │ +│ ·from the ···· ·faintly ·· ∘· │ +│ ··shattering of ·· ·glittering, │ +│ ·some long-dead ·· ·it drifts ·· │ +│ · ··world, this · ··where the · │ +│ ·∘·drifting massif of· ·sunlight ·· │ +│ ·nickel and shadowed· ·finally · │ +│ ·ice has wandered ·· ·grows ·· │ +│ ·the cold reaches for· ·too ··· │ +│ ·an age, its pitted · ··thin · │ +│ ·face a record of · ······· │ +│ ·every blow the dark·· │ +│ ·has dealt it, · │ +│ ·tumbling on without· │ +│ ··haste or heading ·· │ +│ ·through the long · │ +│ ··night between ·· │ +│ ··the ··· │ +│ ··scattered ·· │ +│ ·stars. ··· │ +│ ·········· │ +│ │ +│ │ +│ ↑ │ +│ │ +│ │ +│ │ +│ │ +└─ TextMeasure.jl · Asteroid TUI ──────────────────────────────────────────────────────────────────────────────────┘ \ No newline at end of file diff --git a/examples/asteroid_tui/test/runtests.jl b/examples/asteroid_tui/test/runtests.jl new file mode 100644 index 0000000..15c035f --- /dev/null +++ b/examples/asteroid_tui/test/runtests.jl @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI +using Test + +@testset "AsteroidTUI" begin + # MAJOR #1: retain the weakdep-extension activation regression check — the only + # assertion that the FIGlet ext (cell-space measurement) actually loaded. + @testset "dependency surface" begin + @test AsteroidTUI._ext_loaded() + end + + for f in ("test_input.jl", "test_cellbuffer.jl", "test_cellbackend.jl", "test_prose.jl", + "test_pack.jl", "test_game.jl", "test_fracture.jl", "test_draw.jl", + "test_gameloop.jl", "test_golden.jl") + include(f) + end +end diff --git a/examples/asteroid_tui/test/test_cellbackend.jl b/examples/asteroid_tui/test/test_cellbackend.jl new file mode 100644 index 0000000..8f923c4 --- /dev/null +++ b/examples/asteroid_tui/test/test_cellbackend.jl @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI: CellBackend, pack_prose_into +import TextMeasure +using Test + +@testset "CellBackend" begin + b = CellBackend() + @test TextMeasure.measure(b, "rock") == 4.0 + @test TextMeasure.measure(b, "") == 0.0 + @test TextMeasure.measure(b, "a b") == 3.0 # space counts as a cell + m = TextMeasure.font_metrics(b) + @test (m.ascent, m.descent, m.line_advance) == (1.0, 0.0, 1.0) + # prepare/segments use it transparently + p = TextMeasure.prepare(b, "iron ore") + @test [s.kind for s in p.segments] == [:word, :space, :word] + @test p.segments[1].width == 4.0 && p.segments[3].width == 3.0 + + # MINOR #5: integer-cell property — two stacked lines land on ADJACENT integer + # rows. This hinges on metrics (1,0,1) + shape_pack line_advance=1.0. Pack a + # narrow column so the two words wrap to two lines, and assert their baselines + # differ by exactly 1. + using TextMeasureLayouts: shape_pack + p2 = TextMeasure.prepare(b, "iron rock") + # rectangle chord_fn 4 cells wide ⇒ "iron"(4) fits, "rock"(4) wraps to next line + rect_cf(yt, yb) = [(0.0, 4.0)] + pk = shape_pack(p2, rect_cf; line_advance = 1.0, min_chord_width = 3.0) + word_pls = [pl for pl in pk.placements if p2.segments[pl.segment_index].kind === :word] + @test length(word_pls) == 2 + ys = sort([pl.y for pl in word_pls]) + @test ys[2] - ys[1] == 1.0 # adjacent integer rows + @test all(pl -> pl.y == round(pl.y), word_pls) # integer baselines +end diff --git a/examples/asteroid_tui/test/test_cellbuffer.jl b/examples/asteroid_tui/test/test_cellbuffer.jl new file mode 100644 index 0000000..3808bff --- /dev/null +++ b/examples/asteroid_tui/test/test_cellbuffer.jl @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI: CellBuffer, clear!, put_char!, put_string!, checksum, to_text, nrows, ncols +using Test + +@testset "CellBuffer" begin + b = CellBuffer(3, 5) + @test nrows(b) == 3 && ncols(b) == 5 + @test all(==(' '), b.chars) + + put_string!(b, 1, 1, "AB") + put_char!(b, 2, 3, 'X'; fg=UInt8(9), bold=true) + @test b.chars[1,1] == 'A' && b.chars[1,2] == 'B' + @test b.chars[2,3] == 'X' && b.fg[2,3] == 0x09 && b.bold[2,3] + + # out-of-bounds writes are ignored, not errors + put_char!(b, 99, 99, 'Z') + put_string!(b, 1, 4, "WXYZ") # clips at col 5 + @test b.chars[1,4] == 'W' && b.chars[1,5] == 'X' + + # checksum is content-defined and stable + b2 = CellBuffer(3, 5); put_string!(b2, 1, 1, "AB"); put_char!(b2, 2, 3, 'X'; fg=UInt8(9), bold=true) + put_string!(b2, 1, 4, "WX") + @test checksum(b) == checksum(b2) # same content ⇒ same checksum + clear!(b2) + @test checksum(b) != checksum(b2) + @test checksum(CellBuffer(3,5)) == checksum(CellBuffer(3,5)) # empty stable + @test to_text(CellBuffer(1,3)) == " " + + # newline in put_string! advances row, resets col + b3 = CellBuffer(2, 3); put_string!(b3, 1, 1, "AB\nCD") + @test b3.chars[1,1] == 'A' && b3.chars[1,2] == 'B' + @test b3.chars[2,1] == 'C' && b3.chars[2,2] == 'D' +end diff --git a/examples/asteroid_tui/test/test_draw.jl b/examples/asteroid_tui/test/test_draw.jl new file mode 100644 index 0000000..4eb03b2 --- /dev/null +++ b/examples/asteroid_tui/test/test_draw.jl @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI: new_game, tick!, Input, draw!, CellBuffer, to_text, ship_visible +using Random +using Test + +@testset "draw!" begin + g = new_game(Xoshiro(21); width=80, height=24, n_asteroids=3) + for _ in 1:10; tick!(g, Input(up=true)); end + buf = CellBuffer(g.height, g.width) + draw!(buf, g) + txt = to_text(buf) + @test length(txt) > g.width # something was drawn + @test count(!=(' '), buf.chars) > 0 + # debug overlay recolors prose cells cyan (45) + g.debug = true + buf2 = CellBuffer(g.height, g.width); draw!(buf2, g) + @test count(==(UInt8(45)), buf2.fg) >= count(==(UInt8(45)), buf.fg) + # determinism: same state ⇒ same buffer + bufA = CellBuffer(g.height, g.width); draw!(bufA, g) + bufB = CellBuffer(g.height, g.width); draw!(bufB, g) + @test bufA.chars == bufB.chars && bufA.fg == bufB.fg + # single 8-way arrow ship glyph + g.debug = false + g.ship.alive = true; g.ship.invuln = 0; g.ship.charge = 0 + empty!(g.asteroids); empty!(g.shards); empty!(g.projectiles) # isolate the ship + g.ship.x = 40.0; g.ship.y = 12.0 + sx = round(Int, g.ship.x); sy = round(Int, g.ship.y) + # single 8-way arrow at the ship cell — no separate hull or nose + g.ship.φ = 0.0 + b0 = CellBuffer(g.height, g.width); draw!(b0, g) + @test b0.chars[sy, sx] == '↑' # points up at φ=0 + @test b0.chars[sy, sx] != '▮' # old hull gone + @test b0.chars[sy - 1, sx] == ' ' # no separate nose cell above + @test b0.chars[sy + 1, sx] == ' ' # no hull/plume below + g.ship.φ = π/2 + b1 = CellBuffer(g.height, g.width); draw!(b1, g) + @test b1.chars[sy, sx] == '→' # faces right (catches a CCW table) + g.ship.φ = π/4 + b2 = CellBuffer(g.height, g.width); draw!(b2, g) + @test b2.chars[sy, sx] == '↗' # diagonal + # charge indicator one cell along φ (up at φ=0) + g.ship.φ = 0.0; g.ship.charge = 1 + bc = CellBuffer(g.height, g.width); draw!(bc, g) + @test bc.chars[sy, sx] == '↑' # ship still the arrow + @test bc.chars[sy - 1, sx] == AsteroidTUI.CHARGE_GLYPH[2] # charge glyph one cell ahead + g.ship.charge = 0 +end + +@testset "callout hugs the asteroid prose (no far-floating box)" begin + g = new_game(Xoshiro(5); width=100, height=40, n_asteroids=1) + a = g.asteroids[1] + a.x = 50.0; a.y = 6.0; a.vx = 0.0; a.vy = 0.0; a.θ = 0.0 # near the top ⇒ callout flips below + empty!(g.shards); empty!(g.projectiles) + cb = CellBuffer(40, 100); draw!(cb, g) + nr, nc = size(cb.chars) + # Scope to the interior: exclude the frame border (rows/cols 1 & last) and the + # bottom footer, whose corner glyphs (┌/└) and prose vowels would otherwise be + # mistaken for the callout box / asteroid prose. + interior = 2:(nr - 1) + icols = 2:(nc - 1) + proserows = [r for r in interior if any(c -> c == 'a' || c == 'e' || c == 'o' || c == 'i', cb.chars[r, icols])] + boxrows = [r for r in interior if any(==('┌'), cb.chars[r, icols]) || any(==('└'), cb.chars[r, icols])] + @test !isempty(proserows) && !isempty(boxrows) + # the callout box must sit close to the visible prose, not ~14 rows away + gap = minimum(abs(br - pr) for br in boxrows, pr in proserows) + @test gap <= 4 +end + +@testset "_rotate_poly rotates vertices by θ" begin + p = [AsteroidTUI.GB.Point2{Float64}(1.0, 0.0)] + r = AsteroidTUI._rotate_poly(p, π/2) + @test isapprox(r[1][1], 0.0; atol=1e-9) && isapprox(r[1][2], 1.0; atol=1e-9) # (1,0)→(0,1) + @test AsteroidTUI._rotate_poly(p, 0.0)[1] == p[1] # θ=0 exact identity +end + +@testset "asteroid silhouette rotates with θ (tumbles)" begin + g = new_game(Xoshiro(7); width=80, height=40, n_asteroids=1) + a = g.asteroids[1]; a.x = 40.0; a.y = 20.0 + a.θ = 0.0; b0 = CellBuffer(40, 80); draw!(b0, g) + a.θ = π/2; b1 = CellBuffer(40, 80); draw!(b1, g) + @test b0.chars != b1.chars # the rendered shape changed — it rotated +end diff --git a/examples/asteroid_tui/test/test_fracture.jl b/examples/asteroid_tui/test/test_fracture.jl new file mode 100644 index 0000000..613dd4d --- /dev/null +++ b/examples/asteroid_tui/test/test_fracture.jl @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI: new_game, tick!, Input, fracture_asteroid!, GameState, + Asteroid, CellBackend, _word_boundary_splits +import TextMeasure +import GeometryBasics as GB +using Random +using Test + +# helper: words (in order) of a Prepared +words(p) = [s.str for s in p.segments if s.kind === :word] + +@testset "word-boundary fracture preserves glyphs" begin + g = new_game(Xoshiro(11); n_asteroids=1) + a = g.asteroids[1] + original = words(a.prep) + impact = GB.Point2{Float64}(0.0, 0.0) + fracture_asteroid!(g, 1, impact) + @test isempty(g.asteroids) # the hit asteroid is removed + @test length(g.shards) >= 2 # at least two shard-prose chunks + # concatenating shard words in shard order reproduces the original word order + rebuilt = vcat((words(sh.prep) for sh in g.shards)...) + @test rebuilt == original # no drops, no dups, in order + @test g.last_hit_glyphs == original +end + +# MAJOR #3: short prose (few words) + a high requested shard count must NOT produce +# empty/mismatched ranges or silently truncate via zip. Drive the collision path. +@testset "fracture: short prose, high shard count (no silent truncation)" begin + # _word_boundary_splits invariants directly: 3 words, ask for 4 chunks. + prep3 = TextMeasure.prepare(CellBackend(), "alpha beta gamma") + for n in 1:6 + rs = _word_boundary_splits(prep3, n) + @test all(!isempty, rs) # no empty range + @test first(first(rs)) == 1 # tile starts at 1 + @test last(last(rs)) == length(prep3.segments) # tile ends at last seg + for j in 2:length(rs) # contiguous, no gaps + @test first(rs[j]) == last(rs[j-1]) + 1 + end + rebuilt = vcat(([s.str for s in prep3.segments[r] if s.kind === :word] for r in rs)...) + @test rebuilt == ["alpha", "beta", "gamma"] # lossless, in order + end + + # Full fracture with a deliberately short-prose asteroid and n_shards path. + g = new_game(Xoshiro(99); n_asteroids=1) + short_prep = TextMeasure.prepare(CellBackend(), "iron ore here") # 3 words + a = g.asteroids[1] + g.asteroids[1] = Asteroid(a.poly, a.x, a.y, a.vx, a.vy, a.ω, a.θ, a.radius, short_prep, a.age) + orig = words(g.asteroids[1].prep) + fracture_asteroid!(g, 1, GB.Point2{Float64}(0.0, 0.0)) + @test !isempty(g.shards) + rebuilt = vcat((words(sh.prep) for sh in g.shards)...) + @test rebuilt == orig # every glyph once, in order +end + +@testset "fracture: off-centre rim impact (frame conversion, no truncation)" begin + g = new_game(Xoshiro(11); n_asteroids=1) + a = g.asteroids[1] + original = words(a.prep) + nword = count(s -> s.kind === :word, a.prep.segments) + requested = 2 + (nword >= 6 ? 2 : 0) + impact = GB.Point2{Float64}(a.radius * 0.8, 0.0) # cell-space offset near the rim + fracture_asteroid!(g, 1, impact) + @test isempty(g.asteroids) + rebuilt = vcat((words(sh.prep) for sh in g.shards)...) + @test rebuilt == original # lossless + @test g.last_hit_glyphs == original + @test length(g.shards) == requested # seeds landed INSIDE ⇒ no truncation +end diff --git a/examples/asteroid_tui/test/test_game.jl b/examples/asteroid_tui/test/test_game.jl new file mode 100644 index 0000000..58fa113 --- /dev/null +++ b/examples/asteroid_tui/test/test_game.jl @@ -0,0 +1,175 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI: new_game, tick!, Input, CHARGE_MAX, kill_ship!, ship_visible, + aim_heading +using Random +using Test + +@testset "tick! physics" begin + g = new_game(Xoshiro(42); width=120, height=40, n_asteroids=3) + for _ in 1:5; tick!(g, Input(up=true)); end # strafe imparts velocity, world wraps + @test (g.ship.vx, g.ship.vy) != (0.0, 0.0) + @test 0 <= g.ship.x <= g.width && 0 <= g.ship.y <= g.height + gs = new_game(Xoshiro(42); width=120, height=40, n_asteroids=3); φ0 = gs.ship.φ + tick!(gs, Input(left=true)) + @test gs.ship.vx != 0.0 && gs.ship.φ == φ0 # `left` STRAFES, does NOT turn + ga = new_game(Xoshiro(42); width=120, height=40, n_asteroids=3) + tick!(ga, Input(aim=(80.0, 5.0))) # aim is the cursor cell + @test ga.ship.φ == aim_heading(ga.ship.x, ga.ship.y, 80.0, 5.0) + φ1 = ga.ship.φ; tick!(ga, Input()); @test ga.ship.φ == φ1 # aim===nothing ⇒ φ held + g2 = new_game(Xoshiro(1)) + for _ in 1:20; tick!(g2, Input(fire=true)); end + @test g2.ship.charge == CHARGE_MAX + tick!(g2, Input(fire=false)); @test !isempty(g2.projectiles) && g2.ship.charge == 0 + g4 = new_game(Xoshiro(2)); @test !g4.debug + tick!(g4, Input(debug=true)); @test g4.debug # edge: one press toggles on + tick!(g4, Input(debug=true)); @test g4.debug # held ⇒ NOT re-toggled (no strobe) + tick!(g4, Input(debug=false)); tick!(g4, Input(debug=true)); @test !g4.debug # next press toggles +end + +@testset "spawn protection" begin + g = new_game(Xoshiro(8)) + @test g.ship.invuln > 0 && ship_visible(g) # fresh ship invulnerable AND visible at tick 0 +end + +@testset "respawn + invuln" begin + g = new_game(Xoshiro(8)) + kill_ship!(g) + @test !g.ship.alive + # respawn after the death timer; gains invulnerability + for _ in 1:65; tick!(g, Input()); end + @test g.ship.alive && g.ship.invuln > 0 + # invuln blink: visibility alternates over ticks + vis = [ (tick!(g, Input()); ship_visible(g)) for _ in 1:8 ] + @test any(vis) && any(!, vis) +end + +@testset "aim_heading (visual space, up=0 clockwise)" begin + # ship at (40,12); cells ~2:1 so the row delta is doubled. + @test isapprox(aim_heading(40.0, 12.0, 40.0, 2.0), 0.0; atol=1e-9) # cursor above ⇒ up + @test isapprox(aim_heading(40.0, 12.0, 60.0, 12.0), pi/2; atol=1e-9) # right ⇒ +π/2 + @test isapprox(abs(aim_heading(40.0, 12.0, 40.0, 22.0)), pi; atol=1e-9) # below ⇒ ±π + @test isapprox(aim_heading(40.0, 12.0, 20.0, 12.0), -pi/2; atol=1e-9) # left ⇒ −π/2 +end + +@testset "asteroid bounce separates overlapping pair (below threshold)" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=2) + a, b = g.asteroids[1], g.asteroids[2] + a.x=50.0; a.y=20.0; a.vx=0.0; a.vy=0.0 + b.x=50.0+(a.radius+b.radius)*0.5; b.y=20.0; b.vx=-0.05; b.vy=0.0 # low closing speed + n0 = length(g.asteroids); d0 = hypot(b.x - a.x, b.y - a.y) + @test d0 < a.radius + b.radius + tick!(g, Input()) + @test length(g.asteroids) == n0 # bounce, not fracture + a2,b2 = g.asteroids[1], g.asteroids[2]; d1 = hypot(b2.x - a2.x, b2.y - a2.y) + @test d1 >= d0 # pushed apart + @test all(a -> -a.radius <= a.x <= g.width + a.radius && + -a.radius <= a.y <= g.height + a.radius, g.asteroids) # push-apart clamps within despawn bounds +end + +@testset "high closing speed fractures both" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=2) + a, b = g.asteroids[1], g.asteroids[2] + a.x=60.0; a.y=20.0; a.vx=2.0; a.vy=0.0 + b.x=60.0+(a.radius+b.radius)*0.5; b.y=20.0; b.vx=-2.0; b.vy=0.0 # head-on, high closing + shards0 = length(g.shards) + tick!(g, Input()) + # Both original asteroids fracture into shards; _replenish_field! adds exactly 1 back. + @test length(g.shards) > shards0 + @test length(g.asteroids) == 1 # 0 from fracture + 1 from replenish = exactly 1 +end + +@testset "ship dies on asteroid contact" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=1) + g.ship.invuln = 0 # drop spawn protection for the test + a = g.asteroids[1]; a.vx=0.0; a.vy=0.0; a.x=g.ship.x; a.y=g.ship.y + tick!(g, Input()) + @test !g.ship.alive && length(g.asteroids) == 1 # death; asteroid NOT removed + # drive until the ship respawns (asteroid pinned to a corner so the fresh, + # invulnerable ship at centre isn't immediately re-killed). Loop-until-alive is + # robust to respawn_in / INVULN_TICKS constant changes; 200 is a safety cap. + for _ in 1:200 + g.asteroids[1].x=0.0; g.asteroids[1].y=0.0; tick!(g, Input()) + g.ship.alive && break + end + @test g.ship.alive && g.ship.invuln > 0 # respawned, invulnerable +end + +@testset "invulnerable ship survives contact" begin + g = new_game(Xoshiro(3); n_asteroids=1) # fresh ship has invuln=INVULN_TICKS>0, so the collision is skipped + a = g.asteroids[1]; a.vx=0.0; a.vy=0.0; a.x=g.ship.x; a.y=g.ship.y + tick!(g, Input()) + @test g.ship.alive +end + +@testset "fire launches a charge-sized burst" begin + g = new_game(Xoshiro(1); n_asteroids=0) + for _ in 1:20; tick!(g, Input(fire=true)); end + @test g.ship.charge == CHARGE_MAX + tick!(g, Input(fire=false)) + @test g.ship.charge == 0 + @test length(g.projectiles) == 1 + CHARGE_MAX # burst = 1 + charge +end + +@testset "projectile fractures an asteroid it reaches" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=1) + g.ship.invuln = 1_000_000 + a = g.asteroids[1] + g.ship.x = 60.0; g.ship.y = 30.0; g.ship.φ = 0.0 # firing straight up + a.x = 60.0; a.y = 5.0; a.vx = 0.0; a.vy = 0.0 # rock near the top, dead ahead + shards0 = length(g.shards) + for _ in 1:20; tick!(g, Input(fire=true)); end # charge to max + tick!(g, Input(fire=false)) # launch the burst upward + for _ in 1:30; tick!(g, Input()); end # bullets travel up into the rock + @test length(g.shards) > shards0 # it got fractured (shards spawned) +end + +@testset "projectiles do not wrap — despawn off-screen" begin + g = new_game(Xoshiro(3); n_asteroids=0) + g.ship.x = 60.0; g.ship.y = 20.0; g.ship.φ = 0.0 + tick!(g, Input(fire=true)); tick!(g, Input(fire=false)) # one bullet, heading up + for _ in 1:60; tick!(g, Input()); end # it flies off the top + @test isempty(g.projectiles) # gone, not wrapped to the bottom +end + +@testset "field replenish restores count to N" begin + N = 4 + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=N) + g.ship.invuln = 1_000_000 # keep ship alive; don't perturb the test + empty!(g.asteroids) + tick!(g, Input()) + @test length(g.asteroids) == 1 # one spawned per tick + # _replenish_field! runs LAST in tick! (after _advance_asteroids!), so the just-spawned + # asteroid is still exactly on its edge this tick — assert it here, before it drifts. + a = g.asteroids[1] + @test a.x == 0.0 || a.x == g.width || a.y == 0.0 || a.y == g.height + for _ in 1:10; tick!(g, Input()); end + @test length(g.asteroids) == N # caps at N (g.n_target), never exceeds +end + +@testset "asteroids do not wrap — they leave and despawn off-screen" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=1) + g.ship.invuln = 1_000_000 # keep ship out of it + g.n_target = 0 # disable replenish so we can observe the despawn + a = g.asteroids[1] + a.x = 2.0; a.y = 20.0; a.vx = -1.0; a.vy = 0.0 # heading off the LEFT edge + for _ in 1:40; tick!(g, Input()); end + @test isempty(g.asteroids) # gone — NOT wrapped around to x≈width +end + +@testset "shards do not wrap — they leave and despawn" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=0) + g.ship.invuln = 1_000_000; g.n_target = 0 + # hand-make a shard heading off the top edge (use the real Shard constructor field + # order you read in entities.jl: poly,x,y,vx,vy,prep,ttl,radius) + a = AsteroidTUI.new_game(Xoshiro(7); n_asteroids=1).asteroids[1] # borrow a prep+poly + push!(g.shards, AsteroidTUI.Shard(a.poly, 60.0, 2.0, 0.0, -1.0, a.prep, 1000, 4.0)) + for _ in 1:40; tick!(g, Input()); end + @test isempty(g.shards) # despawned off the top, did not wrap to the bottom +end + +@testset "ship still wraps" begin + g = new_game(Xoshiro(3); width=120, height=40, n_asteroids=0) + g.ship.x = 0.5; g.ship.y = 20.0 + for _ in 1:8; tick!(g, Input(left=true)); end # strafe left, crosses x=0 + @test 0 <= g.ship.x <= g.width # wrapped, stayed in range +end diff --git a/examples/asteroid_tui/test/test_gameloop.jl b/examples/asteroid_tui/test/test_gameloop.jl new file mode 100644 index 0000000..6248cc3 --- /dev/null +++ b/examples/asteroid_tui/test/test_gameloop.jl @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: MIT +# +# HEADLESS GAME-LOOP SMOKE TEST — proves the ACTUAL run.jl code path runs without a +# TTY. run.jl calls `run_game`, which builds a real game + CellBuffer and drives +# `game_loop!` with terminal-backed poll/present/pace callbacks. The earlier subtests +# drive `game_loop!` directly with scripted/headless callbacks (loop logic, input +# dispatch, tick!, draw!, Tachikoma buffer drain). The final subtest calls the REAL +# `run_game(; io=IOBuffer())`: the headless terminal is built over that IOBuffer +# (explicit size, no TTY query), so the real `_present` runs (`draw!` → drain → flush) +# and we assert it flushed bytes (`position(io) > 0`). Only the raw-mode keypress +# capture (`_poll_input`), the visible on-screen render, and real-time pacing stay +# TTY-only (the human tier-2/3 check). +using AsteroidTUI +using AsteroidTUI: GameState, new_game, Input, ScriptedInput, next_input!, CellBuffer, + game_loop!, step_frame!, drain_to_tachikoma!, nrows, ncols, ship_visible +import Tachikoma as TK +using Random +using Test + +# A valid buffer: right dims, every cell a Char, fg a UInt8, bold a Bool. +function _buffer_valid(cb::CellBuffer, h, w) + size(cb.chars) == (h, w) || return false + size(cb.fg) == (h, w) || return false + size(cb.bold) == (h, w) || return false + all(c -> c isa Char, cb.chars) || return false + return true +end + +@testset "headless game-loop smoke" begin + W, H = 80, 24 + + @testset "scripted input — full input vocabulary, no crash" begin + # A scripted sequence exercising every Input field: strafe, aim, charge, + # release-to-fire, debug toggle, idle. Repeats to fill the run. + script = Input[ + Input(left=true), Input(left=true), Input(up=true), Input(up=true), + Input(right=true), Input(fire=true), Input(fire=true), Input(fire=true), + Input(fire=false), Input(aim=(10.0, 3.0)), Input(debug=true), Input(), + Input(down=true), Input(right=true), Input(fire=true), Input(fire=false), + ] + si = ScriptedInput(script) + g = new_game(Xoshiro(123); width=W, height=H, n_asteroids=4) + cb = CellBuffer(H, W) + + # Validate the buffer EVERY frame via the present callback (catches a mid-run + # crash / bad dims / out-of-bounds the moment it happens). + bad_frames = Int[] + frames = game_loop!(g, cb; + poll = frame -> next_input!(si), + present = (buf, frame) -> (_buffer_valid(buf, H, W) || push!(bad_frames, frame)), + pace = frame -> nothing, + max_frames = 120) + + @test frames == 120 # loop ran to completion, no early crash + @test isempty(bad_frames) # every frame produced a valid buffer + @test g.tick_count == 120 # tick! actually advanced each frame + # the ship wraps, so it stays in-bounds after 120 ticks of motion + @test 0 <= g.ship.x <= W && 0 <= g.ship.y <= H + # asteroids/shards no longer wrap — they leave and despawn once fully + # off-screen, so any live body's centre is within its radius of the field + @test all(a -> -a.radius <= a.x <= W + a.radius && -a.radius <= a.y <= H + a.radius, g.asteroids) + end + + @testset "no-input run — idles cleanly" begin + g = new_game(Xoshiro(7); width=W, height=H, n_asteroids=5) + cb = CellBuffer(H, W) + ok = true + frames = game_loop!(g, cb; + poll = frame -> Input(), + present = (buf, frame) -> (ok &= _buffer_valid(buf, H, W)), + pace = frame -> nothing, + max_frames = 90) + @test frames == 90 + @test ok + @test g.tick_count == 90 + end + + @testset "quit input stops the loop early" begin + g = new_game(Xoshiro(1); width=W, height=H) + cb = CellBuffer(H, W) + frames = game_loop!(g, cb; + poll = frame -> (frame == 10 ? Input(quit=true) : Input(up=true)), + present = (buf, frame) -> nothing, + pace = frame -> nothing, + max_frames = 1000) + @test frames == 10 # stopped at the quit, not max_frames + @test g.tick_count == 10 + end + + @testset "entities evolve under motion" begin + g = new_game(Xoshiro(42); width=W, height=H, n_asteroids=3) + cb = CellBuffer(H, W) + x0, y0 = g.ship.x, g.ship.y + θ0 = [a.θ for a in g.asteroids] + game_loop!(g, cb; + poll = frame -> Input(up=true, right=true), + present = (buf, frame) -> nothing, + pace = frame -> nothing, + max_frames = 30) + @test (g.ship.x, g.ship.y) != (x0, y0) # ship moved + @test any(((a, t),) -> a.θ != t, zip(g.asteroids, θ0)) || !isempty(g.shards) + end + + @testset "step_frame! + Tachikoma drain (the real render path, headless)" begin + # Exercises the SAME drain run_game uses to fill a Tachikoma Buffer — proves + # the buffer fill works in-memory without a terminal. + g = new_game(Xoshiro(5); width=W, height=H, n_asteroids=2) + cb = CellBuffer(H, W) + step_frame!(g, cb, Input(up=true)) + tbuf = TK.Buffer(TK.Rect(0, 0, W, H)) + @test drain_to_tachikoma!(tbuf, cb) === tbuf # completes, returns the buffer + end + + @testset "run_game itself runs headless (bounded, injected io, no TTY)" begin + # run.jl's EXACT entry point: we call the real `run_game`, not a stand-in. + # Passing `io=IOBuffer()` builds the Tachikoma Terminal over that buffer with + # an explicit size, so it never queries a TTY and the headless branch (no + # keys, real `_present` → `draw!` → flush, no pacing) runs end to end. This is + # the closest possible proof that `julia run.jl` boots, loops, and renders + # without crashing. (`_poll_input`'s raw-mode key capture stays TTY-only — the + # human tier-2/3 check.) + io = IOBuffer() + g = run_game(; width=W, height=H, seed=3, max_frames=60, io=io) + @test g isa GameState + @test g.tick_count == 60 # the loop ran every frame + @test position(io) > 0 # _present actually flushed bytes to the terminal io + end +end diff --git a/examples/asteroid_tui/test/test_golden.jl b/examples/asteroid_tui/test/test_golden.jl new file mode 100644 index 0000000..baf0d62 --- /dev/null +++ b/examples/asteroid_tui/test/test_golden.jl @@ -0,0 +1,64 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI: new_game, draw!, CellBuffer, checksum, to_text, CellBackend, ship_visible +import TextMeasure +using Random + +const GOLDEN_DIR = joinpath(@__DIR__, "golden") + +# Curated showcase prose — long enough that the dominant silhouette fills as a real +# shaped-text MASS (the blob fills to the prose's glyph count, so a short blurb would +# spread thin across a large shape). Hand-tuned copy, not the procedural template. +const _HERO_PROSE = + "Born from the shattering of some long-dead world, this drifting massif of nickel and " * + "shadowed ice has wandered the cold reaches for an age, its pitted face a record of every " * + "blow the dark has dealt it, tumbling on without haste or heading through the long night " * + "between the scattered stars." +const _MED_PROSE = + "Porous, fast, and faintly glittering, it drifts where the sunlight finally grows too thin " * + "to warm a stone." + +_prep(s) = TextMeasure.prepare(CellBackend(), s) + +# The committed showcase scene (design-reviewer pick, FEATURABLE): a single dominant +# rounded text-mass (left-center, the engine shaping ~300 chars of prose into a +# silhouette) + a smaller receding intact asteroid (upper-right) + the ship (a single +# 8-way arrow glyph — `↑` at φ=0). Hand-placed for composition; velocities are +# NONZERO so the stat readouts show the field in motion even in a still frame. The +# silhouette POLYGONS come from `Xoshiro(38)` via `new_game`, so the whole frame is +# deterministic and reproducible. This is a STATIC composed frame (`draw!` only, no +# tick loop): the headless tick-loop / determinism path is covered by test_game.jl, +# and the order-exact fracture glyph-preservation acceptance by test_fracture.jl. This +# golden regenerates ONLY when `draw.jl` visuals change (not on `tick!`/physics changes) +# and is the render-regression anchor for the gallery showcase frame. +function _run_golden() + g = new_game(Xoshiro(38); width = 116, height = 36, n_asteroids = 2) + g.ship.x = 58.0; g.ship.y = 31.0; g.ship.φ = 0.0; g.ship.vx = 0.0; g.ship.vy = -0.15 + a1, a2 = g.asteroids + a1.x = 32.0; a1.y = 18.0; a1.vx = 0.22; a1.vy = 0.10; a1.ω = 0.012; a1.radius = 10.0; a1.prep = _prep(_HERO_PROSE) + a2.x = 93.0; a2.y = 12.0; a2.vx = -0.18; a2.vy = 0.06; a2.ω = -0.02; a2.radius = 6.0; a2.prep = _prep(_MED_PROSE) + buf = CellBuffer(g.height, g.width) + draw!(buf, g) + return g, buf +end + +@testset "golden showcase frame" begin + g, buf = _run_golden() + # Non-vacuous scene assertions (the frame is the gallery showcase, not a fracture). + @test length(g.asteroids) == 2 + @test ship_visible(g) + @test count(!=(' '), buf.chars) > 200 # the dominant mass actually fills + + cs = checksum(buf) + golden_path = joinpath(GOLDEN_DIR, "frame60.sha256") + if get(ENV, "UPDATE_GOLDEN", "") == "1" + mkpath(GOLDEN_DIR) + write(golden_path, cs) + write(joinpath(GOLDEN_DIR, "frame60.txt"), to_text(buf)) + end + @test isfile(golden_path) + @test cs == strip(read(golden_path, String)) # regression anchor + + # NOTE: fracture glyph-preservation (each glyph once, IN ORDER) is enforced + # non-vacuously in test_fracture.jl; tick-loop determinism in test_game.jl. This + # golden pins the *rendered showcase frame* only. +end diff --git a/examples/asteroid_tui/test/test_input.jl b/examples/asteroid_tui/test/test_input.jl new file mode 100644 index 0000000..7b17b43 --- /dev/null +++ b/examples/asteroid_tui/test/test_input.jl @@ -0,0 +1,36 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI: Input, sweep_stale!, fold_input, InputState, DECAY_WINDOW +using Test + +@testset "input helpers (headless, no terminal)" begin + @testset "sweep_stale! evicts only keys older than the window" begin + held = Dict{Tuple{Symbol,Char},Int}((:char,'w')=>10, (:char,'a')=>4, (:char,'d')=>6) + ret = sweep_stale!(held, 10, DECAY_WINDOW) # DECAY_WINDOW == 5 + @test ret === held + @test haskey(held, (:char,'w')) && haskey(held, (:char,'d')) + @test !haskey(held, (:char,'a')) # 10-4==6 > 5 ⇒ evicted + end + @testset "sweep_stale! boundary now-last==window is kept" begin + held = Dict{Tuple{Symbol,Char},Int}((:char,'w')=>5) + sweep_stale!(held, 10, 5); @test haskey(held, (:char,'w')) # 5 not > 5 + sweep_stale!(held, 11, 5); @test !haskey(held, (:char,'w')) # 6 > 5 + end + @testset "fold_input maps held keys to strafe + Space-fire + quit" begin + st = InputState() + st.held[(:char,'w')]=0; st.held[(:char,'d')]=0; st.held[(:char,' ')]=0 + inp = fold_input(st, 0) + @test inp.up && inp.right && !inp.down && !inp.left + @test inp.fire # Space ⇒ fire + @test inp.aim === nothing # no cursor yet + st2 = InputState(); st2.held[(:left,'\0')]=0; st2.held[(:escape,'\0')]=0 + inp2 = fold_input(st2, 0); @test inp2.left && inp2.quit + end + @testset "fold_input fire from lmb_down" begin + st = InputState(); st.lmb_down = true + @test fold_input(st, 0).fire + end + @testset "fold_input emits the cursor as aim (raw, no φ math here)" begin + st = InputState(); st.cursor = (60, 2) + @test fold_input(st, 0).aim == (60.0, 2.0) # passed through; φ is computed in the sim + end +end diff --git a/examples/asteroid_tui/test/test_pack.jl b/examples/asteroid_tui/test/test_pack.jl new file mode 100644 index 0000000..4e8523a --- /dev/null +++ b/examples/asteroid_tui/test/test_pack.jl @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI: CellBackend, pack_prose_into, PackedProse +import TextMeasure +using Silhouettes: asteroid_polygon +using Random +using Test + +@testset "pack_prose_into" begin + rng = Xoshiro(3) + poly = asteroid_polygon(rng; n=12, lumpiness=0.3) + prep = TextMeasure.prepare(CellBackend(), "iron rock spins fast cold dense ore here now") + pp = pack_prose_into(poly, prep; scale=18.0, min_chord_width=3.0) + @test pp isa PackedProse + @test pp.rows >= 3 && pp.cols >= 3 + @test !isempty(pp.cells) # (row, col, char) tuples + # all placed cells are inside the raster bounds + @test all(1 <= r <= pp.rows && 1 <= c <= pp.cols for (r, c, _ch) in pp.cells) + # determinism + pp2 = pack_prose_into(poly, prep; scale=18.0, min_chord_width=3.0) + @test pp.cells == pp2.cells +end diff --git a/examples/asteroid_tui/test/test_prose.jl b/examples/asteroid_tui/test/test_prose.jl new file mode 100644 index 0000000..271da07 --- /dev/null +++ b/examples/asteroid_tui/test/test_prose.jl @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: MIT +using AsteroidTUI: asteroid_prose, PROSE_VARIANTS +using Random +using Test + +@testset "prose pool" begin + @test PROSE_VARIANTS() >= 50 + rng = Xoshiro(7) + s = asteroid_prose(rng) + @test s isa String && length(split(s)) >= 6 + # deterministic by seed + @test asteroid_prose(Xoshiro(1)) == asteroid_prose(Xoshiro(1)) + # variety: 40 draws give many distinct strings + seen = Set(asteroid_prose(Xoshiro(i)) for i in 1:40) + @test length(seen) >= 30 + # no global RNG side effects + Random.seed!(123); a = rand(); Random.seed!(123); asteroid_prose(Xoshiro(9)); b = rand() + @test a == b +end diff --git a/examples/fonts/Fraunces/Fraunces144pt-Black.ttf b/examples/fonts/Fraunces/Fraunces144pt-Black.ttf new file mode 100644 index 0000000..c53c6e5 Binary files /dev/null and b/examples/fonts/Fraunces/Fraunces144pt-Black.ttf differ diff --git a/examples/fonts/Fraunces/Fraunces144pt-Light.ttf b/examples/fonts/Fraunces/Fraunces144pt-Light.ttf new file mode 100644 index 0000000..bc6e3db Binary files /dev/null and b/examples/fonts/Fraunces/Fraunces144pt-Light.ttf differ diff --git a/examples/fonts/Fraunces/Fraunces144pt-Regular.ttf b/examples/fonts/Fraunces/Fraunces144pt-Regular.ttf new file mode 100644 index 0000000..8e564df Binary files /dev/null and b/examples/fonts/Fraunces/Fraunces144pt-Regular.ttf differ diff --git a/examples/fonts/Fraunces/Fraunces72pt-Black.ttf b/examples/fonts/Fraunces/Fraunces72pt-Black.ttf new file mode 100644 index 0000000..979974c Binary files /dev/null and b/examples/fonts/Fraunces/Fraunces72pt-Black.ttf differ diff --git a/examples/fonts/Fraunces/Fraunces72pt-Regular.ttf b/examples/fonts/Fraunces/Fraunces72pt-Regular.ttf new file mode 100644 index 0000000..0ed4f0c Binary files /dev/null and b/examples/fonts/Fraunces/Fraunces72pt-Regular.ttf differ diff --git a/examples/fonts/Fraunces/Fraunces9pt-Black.ttf b/examples/fonts/Fraunces/Fraunces9pt-Black.ttf new file mode 100644 index 0000000..deb2843 Binary files /dev/null and b/examples/fonts/Fraunces/Fraunces9pt-Black.ttf differ diff --git a/examples/fonts/Fraunces/Fraunces9pt-Italic.ttf b/examples/fonts/Fraunces/Fraunces9pt-Italic.ttf new file mode 100644 index 0000000..00dfb0e Binary files /dev/null and b/examples/fonts/Fraunces/Fraunces9pt-Italic.ttf differ diff --git a/examples/fonts/Fraunces/Fraunces9pt-Regular.ttf b/examples/fonts/Fraunces/Fraunces9pt-Regular.ttf new file mode 100644 index 0000000..6a9236e Binary files /dev/null and b/examples/fonts/Fraunces/Fraunces9pt-Regular.ttf differ diff --git a/examples/fonts/Fraunces/Fraunces9pt-SemiBold.ttf b/examples/fonts/Fraunces/Fraunces9pt-SemiBold.ttf new file mode 100644 index 0000000..a8e7dfd Binary files /dev/null and b/examples/fonts/Fraunces/Fraunces9pt-SemiBold.ttf differ diff --git a/examples/fonts/Fraunces/OFL.txt b/examples/fonts/Fraunces/OFL.txt new file mode 100644 index 0000000..22fa7ce --- /dev/null +++ b/examples/fonts/Fraunces/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2018 The Fraunces Project Authors (https://github.com/undercasetype/Fraunces) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/fonts/IBMPlexMono/IBMPlexMono-Bold.ttf b/examples/fonts/IBMPlexMono/IBMPlexMono-Bold.ttf new file mode 100644 index 0000000..9a168d2 Binary files /dev/null and b/examples/fonts/IBMPlexMono/IBMPlexMono-Bold.ttf differ diff --git a/examples/fonts/IBMPlexMono/IBMPlexMono-Italic.ttf b/examples/fonts/IBMPlexMono/IBMPlexMono-Italic.ttf new file mode 100644 index 0000000..950842a Binary files /dev/null and b/examples/fonts/IBMPlexMono/IBMPlexMono-Italic.ttf differ diff --git a/examples/fonts/IBMPlexMono/IBMPlexMono-Medium.ttf b/examples/fonts/IBMPlexMono/IBMPlexMono-Medium.ttf new file mode 100644 index 0000000..7d991ea Binary files /dev/null and b/examples/fonts/IBMPlexMono/IBMPlexMono-Medium.ttf differ diff --git a/examples/fonts/IBMPlexMono/IBMPlexMono-Regular.ttf b/examples/fonts/IBMPlexMono/IBMPlexMono-Regular.ttf new file mode 100644 index 0000000..4254c37 Binary files /dev/null and b/examples/fonts/IBMPlexMono/IBMPlexMono-Regular.ttf differ diff --git a/examples/fonts/IBMPlexMono/IBMPlexMono-SemiBold.ttf b/examples/fonts/IBMPlexMono/IBMPlexMono-SemiBold.ttf new file mode 100644 index 0000000..a48e40a Binary files /dev/null and b/examples/fonts/IBMPlexMono/IBMPlexMono-SemiBold.ttf differ diff --git a/examples/fonts/IBMPlexMono/IBMPlexMono-Text.ttf b/examples/fonts/IBMPlexMono/IBMPlexMono-Text.ttf new file mode 100644 index 0000000..993b6fe Binary files /dev/null and b/examples/fonts/IBMPlexMono/IBMPlexMono-Text.ttf differ diff --git a/examples/fonts/IBMPlexMono/OFL.txt b/examples/fonts/IBMPlexMono/OFL.txt new file mode 100644 index 0000000..c35c4c6 --- /dev/null +++ b/examples/fonts/IBMPlexMono/OFL.txt @@ -0,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/examples/fonts/smoke_test.jl b/examples/fonts/smoke_test.jl new file mode 100644 index 0000000..aa239f5 --- /dev/null +++ b/examples/fonts/smoke_test.jl @@ -0,0 +1,56 @@ +# Pre-flight check for the gallery's pinned fonts. +# +# Loads every static TTF in this directory BY FILE PATH through the real +# FreeTypeBackend (measure + font_metrics) — the same path the demos use — and +# fails loudly if any font won't load or render. Run this before kicking off the +# gallery /goal so a bad/missing font surfaces here, not mid-render. +# +# julia --project=test examples/fonts/smoke_test.jl +# +# (test/Project.toml carries FreeTypeAbstraction; the FreeType extension supplies +# measure/font_metrics for FreeTypeBackend.) + +using TextMeasure, FreeTypeAbstraction +const FTA = FreeTypeAbstraction + +const FONTROOT = @__DIR__ + +files = String[] +for d in ("Fraunces", "IBMPlexMono") + dir = joinpath(FONTROOT, d) + isdir(dir) || continue + for f in sort(readdir(dir)) + endswith(f, ".ttf") && push!(files, joinpath(dir, f)) + end +end +isempty(files) && error("no .ttf files found under $FONTROOT") + +loadface(path) = try + FTA.FTFont(path) +catch + FTA.try_load(path) +end + +pass = 0; fail = 0 +for path in files + try + face = loadface(path) + face === nothing && error("loader returned nothing") + b = TextMeasure.FreeTypeBackend(face, 11.0, 72.0) + w = TextMeasure.measure(b, "Measure") + fm = TextMeasure.font_metrics(b) + println("OK ", rpad(basename(path), 30), + " fam=", rpad(FTA.family_name(face), 22), + " adv=", rpad(round(w, digits=2), 7), + " asc=", round(fm.ascent, digits=2), + " desc=", round(fm.descent, digits=2), + " la=", round(fm.line_advance, digits=2)) + global pass += 1 + catch e + println("FAIL ", rpad(basename(path), 30), " ", e) + global fail += 1 + end +end +println("\n", pass, " ok / ", fail, " fail / ", length(files), " total") +fail == 0 || error("font smoke test FAILED") +println("SMOKE TEST PASSED")