commit 874abee6636ff2013a52d9949be826b60ef21145 Author: Lixen Wraith Date: Tue Jul 14 03:56:38 2026 -0400 v0.1.0 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2695a71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +log/ +dev/ +debug/ +catalog.txt +build.sh diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1a36bbd --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2026, Lixen Wraith + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0743e80 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +GO := go + +.PHONY: build build-term build-raylib test clean android-aar + +build: build-term + +build-term: + $(GO) build -o bin/symph-term ./cmd/symph-term + +# purego backend: no cgo, embedded raylib .so for linux amd64/arm64 +build-raylib: + CGO_ENABLED=0 $(GO) build -o bin/symph-raylib ./cmd/symph-raylib + +test: + $(GO) test ./... + +# TODO: blocked on mobile/ facade (Dispatch/Update/snapshot exports) +android-aar: + gomobile bind -target=android -o build/symph.aar ./mobile + +clean: + rm -rf bin build + diff --git a/README.md b/README.md new file mode 100644 index 0000000..114e490 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Symph + +Rhythm-driven grid traversal. Real-time spatial movement over a quantized musical beat, on a 3×3 grid. + +## Concept + +A song advances one chord at a time on the beat (the Z-axis). Each chord is a 3×3 matrix of notes. The player holds a cell on the grid and moves across lanes (X) and rows (Y) to collect items and avoid walls. Lane shifts persist; row shifts are transient and collapse back to the base row after a fixed number of chords — a jump you must ride out or duck out of. Returning into a wall on landing is fatal. + +The engine is a deterministic, side-effect-free state machine. Rendering and input polling run at host framerate (~60Hz), decoupled from beat progression. Frontends translate native input to platform-neutral actions and drain queued gameplay events into audio. + +## Mechanics + +| Item | Glyph | Effect | +|---|---|---| +| Energy | `*` | +1 energy, consumed on contact | +| Magnet | `M` | sweeps every Energy note in the lookahead window, all grid positions | +| Shield | `S` | arms one absorb charge; no expiry | +| Boost | `B` | doubles tempo for a fixed duration; refreshes, does not stack | +| Wall | `#` | fatal, unless a charge absorbs it or a Boost is active | + +Two persistent effects (Shield charges, Boost deadline) cross level boundaries and clear on death. A Boost grants wall immunity but the field keeps rendering walls lethal — Boost expiry inside a wall band is the primary death mode. + +## Build + +```sh +make build-term # terminal frontend (symph-term) +make build-raylib # raylib frontend, linux only (symph-raylib) +make test +``` + +Requires Go 1.26+. The raylib frontend builds CGO-free (embedded `.so`, linux amd64/arm64). + +## Run + +```sh +bin/symph-term +``` + +### Controls + +Vim-style bindings: + +- `h` `j` `k` `l` — left, down, up, right +- `Enter` — restart after game over +- `Ctrl+S` — toggle mute +- `Esc` / `Ctrl+C` / `q` — quit + +Host controls (quit, mute) bypass the engine and work in every phase, including paused. + +## Frontends + +- **Terminal** (`symph-term`): full Unicode glyph set, density-shaded walls, wall-band colored borders. Pauses and shows a notice below minimum viewport size. +- **Raylib** (`symph-raylib`, linux): same presentation contract, drawn with primitives. Substitutes ASCII for the Unicode atlas (default font covers codepoints 32..126); draws walls as rectangles. + +Both share the grid-scan and burn-out projection pipeline in `render`; presentation policy lives there, not in the engine. + +## Audio + +Fire-and-forget effects, PCM synthesized at startup. PipeWire backend (`pw-play`) on Linux; silent `nullEngine` elsewhere or when `pw-play` is absent — the game stays playable without audio. FreeBSD (sndio/OSS) is unimplemented and falls through to silence. + +## Layout + +Unidirectional dependency flow (supports a future CGO/gomobile mobile target): + +- `types/` — dependency-free structures and the value registry (identity, glyph, polarity) +- `parameter/` — compile-time constants: grid bounds, timings, glyphs, geometry +- `game/` — pure state engine; contact behavior and persistent effects +- `input/` — native key → neutral `Key` → `game.Action` +- `render/` — appearance tables and the shared draw pipeline; `render/raylib/` is the graphical frontend +- `audio/` — synthesized effects and platform backends +- `cmd/` — hosts: wiring, event poller, ticker + +Adding an item kind is three rows — identity (`types.valueSpecs`), behavior (`game.resolvers`), appearance (`render.valueVisuals`) — with no switch edits. `init` asserts registry completeness at process start. + +## Status + +Early development. Terminal and raylib frontends are functional. + +Planned: +- **Android** frontend via `gomobile` (`mobile/`, `platform/android/`). The `input` package is bypassed on that path — touch/gesture translates directly to `game.Action`. +- Symphony (multi-song) and Symphace (bounded environment) domain layers. +- Persistent audio stream to replace per-effect `pw-play` spawns before latency matters. +- FreeBSD audio backend (sndio/OSS). + +## License + +See `LICENSE`. + diff --git a/audio/audio.go b/audio/audio.go new file mode 100644 index 0000000..53a534f --- /dev/null +++ b/audio/audio.go @@ -0,0 +1,35 @@ +// Package audio provides fire-and-forget sound effect playback. +// Placeholder backend: pre-synthesized WAVs spawned via pw-play (PipeWire). +// Per-spawn stream connect costs ~tens of ms; replace with a persistent +// stream (libpipewire CGO or raylib audio) once latency matters +package audio + +// Effect identifies a synthesized sound effect +type Effect uint8 + +const ( + EffectNone Effect = iota + EffectPickup + EffectMagnet + EffectShield + EffectShieldBreak + EffectBoost + EffectDeflect + EffectMoveRow + EffectMoveLane + EffectHitWall + EffectLevelClear + EffectCount +) + +// Engine plays effects without blocking the caller +type Engine interface { + Play(Effect) // EffectNone is a no-op + Close() +} + +// nullEngine is the silent fallback (unsupported platform, pw-play absent) +type nullEngine struct{} + +func (nullEngine) Play(Effect) {} +func (nullEngine) Close() {} diff --git a/audio/engine_stub.go b/audio/engine_stub.go new file mode 100644 index 0000000..8799ae1 --- /dev/null +++ b/audio/engine_stub.go @@ -0,0 +1,7 @@ +//go:build !linux + +package audio + +// NewEngine returns the silent engine on platforms without a backend +// (FreeBSD path pending: sndio/OSS) +func NewEngine() Engine { return nullEngine{} } diff --git a/audio/mapping.go b/audio/mapping.go new file mode 100644 index 0000000..10cf021 --- /dev/null +++ b/audio/mapping.go @@ -0,0 +1,30 @@ +package audio + +import "symph/game" + +// MapGameEvent translates engine gameplay events to sound effects +func MapGameEvent(e game.Event) Effect { + switch e { + case game.EventPickup: + return EffectPickup + case game.EventMagnet: + return EffectMagnet + case game.EventShield: + return EffectShield + case game.EventShieldBreak: + return EffectShieldBreak + case game.EventBoost: + return EffectBoost + case game.EventDeflect: + return EffectDeflect + case game.EventMoveRow: + return EffectMoveRow + case game.EventMoveLane: + return EffectMoveLane + case game.EventHitWall: + return EffectHitWall + case game.EventLevelClear: + return EffectLevelClear + } + return EffectNone +} diff --git a/audio/mute.go b/audio/mute.go new file mode 100644 index 0000000..8d07675 --- /dev/null +++ b/audio/mute.go @@ -0,0 +1,27 @@ +package audio + +// Muter gates playback of a wrapped Engine. The mute state is a host concern: +// the engine state machine is unaware and both backends stay untouched +type Muter struct { + Engine + muted bool +} + +func NewMuter(e Engine) *Muter { return &Muter{Engine: e} } + +// Play forwards to the wrapped Engine unless gated. In-flight pw-play +// processes are not killed; effects are under 300ms +func (m *Muter) Play(fx Effect) { + if !m.muted { + m.Engine.Play(fx) + } +} + +// Toggle flips the gate and reports the new muted state +func (m *Muter) Toggle() bool { + m.muted = !m.muted + return m.muted +} + +// Muted reports whether playback is gated +func (m *Muter) Muted() bool { return m.muted } diff --git a/audio/pipewire_linux.go b/audio/pipewire_linux.go new file mode 100644 index 0000000..42f8336 --- /dev/null +++ b/audio/pipewire_linux.go @@ -0,0 +1,53 @@ +//go:build linux + +package audio + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// pwEngine plays pre-rendered WAVs by spawning pw-play per effect +type pwEngine struct { + dir string + files [EffectCount]string +} + +// NewEngine synthesizes effect WAVs into a temp dir. Falls back to a silent +// engine when pw-play is unavailable — game remains playable without audio +func NewEngine() Engine { + if _, err := exec.LookPath("pw-play"); err != nil { + return nullEngine{} + } + dir, err := os.MkdirTemp("", "symph-sfx-") + if err != nil { + return nullEngine{} + } + e := &pwEngine{dir: dir} + for fx := EffectNone + 1; fx < EffectCount; fx++ { + path := filepath.Join(dir, fmt.Sprintf("fx-%d.wav", fx)) + if err := os.WriteFile(path, wavBytes(synth(effectSegs[fx])), 0o644); err != nil { + os.RemoveAll(dir) + return nullEngine{} + } + e.files[fx] = path + } + return e +} + +// Play spawns pw-play detached; process reaped asynchronously +func (e *pwEngine) Play(fx Effect) { + if fx == EffectNone || fx >= EffectCount || e.files[fx] == "" { + return + } + cmd := exec.Command("pw-play", "--volume", "0.7", e.files[fx]) + if err := cmd.Start(); err == nil { + go cmd.Wait() + } +} + +func (e *pwEngine) Close() { + os.RemoveAll(e.dir) +} diff --git a/audio/synth.go b/audio/synth.go new file mode 100644 index 0000000..f3959be --- /dev/null +++ b/audio/synth.go @@ -0,0 +1,81 @@ +package audio + +import ( + "bytes" + "encoding/binary" + "math" +) + +const sampleRate = 48000 // PipeWire native rate + +// seg is one tone segment: linear frequency sweep with pluck envelope +type seg struct { + f0, f1 float64 // Hz + dur float64 // seconds + amp float64 // 0..1 + square bool +} + +// effectSegs defines each effect's tone sequence — the tuning surface +var effectSegs = [EffectCount][]seg{ + EffectPickup: {{988, 988, 0.055, 0.5, false}, {1319, 1319, 0.11, 0.5, false}}, // B5→E6 coin + EffectMagnet: {{523, 1319, 0.10, 0.45, false}, {784, 1976, 0.16, 0.4, false}}, // rising double sweep — reads as a suction, distinct from the coin + EffectShield: {{659, 988, 0.07, 0.45, false}, {1319, 1319, 0.16, 0.40, false}}, // chime up into a held tone — armor + EffectShieldBreak: {{1200, 300, 0.06, 0.50, true}, {700, 180, 0.14, 0.45, true}}, // square shatter; brighter and shorter than the death crunch + EffectBoost: {{392, 1568, 0.16, 0.50, false}, {1568, 1568, 0.10, 0.35, false}}, // fast sweep up — tempo kick + EffectDeflect: {{1200, 1600, 0.025, 0.20, true}, {1600, 900, 0.035, 0.15, true}}, // fires once per walled chord traversed under a Boost — short, dry, low + EffectMoveRow: {{300, 620, 0.07, 0.35, false}}, // whoosh up + EffectMoveLane: {{440, 470, 0.04, 0.28, false}}, // tick + EffectHitWall: {{140, 50, 0.30, 0.6, true}}, // crunch drop + EffectLevelClear: { // C-major arpeggio + {523, 523, 0.09, 0.45, false}, + {659, 659, 0.09, 0.45, false}, + {784, 784, 0.09, 0.45, false}, + {1047, 1047, 0.18, 0.45, false}, + }, +} + +// synth renders segments to 16-bit mono PCM +func synth(segs []seg) []int16 { + var out []int16 + for _, s := range segs { + n := int(s.dur * sampleRate) + phase := 0.0 + for i := range n { + t := float64(i) / float64(n) + f := s.f0 + (s.f1-s.f0)*t + phase += 2 * math.Pi * f / sampleRate + v := math.Sin(phase) + if s.square { + if v >= 0 { + v = 1 + } else { + v = -1 + } + } + env := math.Min(t*24, 1) * math.Exp(-4.2*t) // fast attack, pluck decay + out = append(out, int16(v*env*s.amp*32767)) + } + } + return out +} + +// wavBytes wraps PCM in a minimal RIFF/WAVE container (PCM16 mono) +func wavBytes(samples []int16) []byte { + var b bytes.Buffer + dataLen := uint32(len(samples) * 2) + b.WriteString("RIFF") + binary.Write(&b, binary.LittleEndian, 36+dataLen) + b.WriteString("WAVEfmt ") + binary.Write(&b, binary.LittleEndian, uint32(16)) + binary.Write(&b, binary.LittleEndian, uint16(1)) // PCM + binary.Write(&b, binary.LittleEndian, uint16(1)) // mono + binary.Write(&b, binary.LittleEndian, uint32(sampleRate)) + binary.Write(&b, binary.LittleEndian, uint32(sampleRate*2)) // byte rate + binary.Write(&b, binary.LittleEndian, uint16(2)) // block align + binary.Write(&b, binary.LittleEndian, uint16(16)) // bits/sample + b.WriteString("data") + binary.Write(&b, binary.LittleEndian, dataLen) + binary.Write(&b, binary.LittleEndian, samples) + return b.Bytes() +} diff --git a/cmd/symph-raylib/main.go b/cmd/symph-raylib/main.go new file mode 100644 index 0000000..181bef1 --- /dev/null +++ b/cmd/symph-raylib/main.go @@ -0,0 +1,91 @@ +//go:build linux + +package main + +import ( + "time" + + rl "github.com/gen2brain/raylib-go/raylib" + + "symph/audio" + "symph/game" + "symph/input" + raylibrender "symph/render/raylib" +) + +const ( + defaultScreenW = 800 + defaultScreenH = 600 +) + +func main() { + rl.SetConfigFlags(rl.FlagWindowResizable | rl.FlagVsyncHint) + rl.InitWindow(defaultScreenW, defaultScreenH, "Symph") + defer rl.CloseWindow() + + rl.SetTargetFPS(60) + + state := game.New() + sfx := audio.NewMuter(audio.NewEngine()) + defer sfx.Close() + renderer := raylibrender.NewRenderer(state) + + for !rl.WindowShouldClose() { + now := time.Now() + + // Modifier state is polled, not queued + ctrl := rl.IsKeyDown(rl.KeyLeftControl) || rl.IsKeyDown(rl.KeyRightControl) + + for { + k := rl.GetKeyPressed() + if k == 0 { + break + } + if k == rl.KeyS && ctrl { + renderer.Muted = sfx.Toggle() + continue + } + if ik := keyFromRaylib(k); ik != input.KeyNone { + state.Dispatch(input.Translate(ik), now) + } + } + + state.Update(now) + for _, ge := range state.DrainEvents() { + sfx.Play(audio.MapGameEvent(ge)) + } + + rl.BeginDrawing() + renderer.Draw(int32(rl.GetScreenWidth()), int32(rl.GetScreenHeight()), now) + rl.EndDrawing() + } +} + +// keyFromRaylib translates a raylib keycode to a platform-neutral input.Key +func keyFromRaylib(k int32) input.Key { + switch k { + case rl.KeyH: + return input.KeyH + case rl.KeyJ: + return input.KeyJ + case rl.KeyK: + return input.KeyK + case rl.KeyL: + return input.KeyL + case rl.KeySemicolon: + return input.KeySemicolon + case rl.KeyA: + return input.KeyA + case rl.KeyS: + return input.KeyS + case rl.KeyD: + return input.KeyD + case rl.KeyF: + return input.KeyF + case rl.KeySpace: + return input.KeySpace + case rl.KeyEnter, rl.KeyKpEnter: + return input.KeyEnter + } + return input.KeyNone +} diff --git a/cmd/symph-term/main.go b/cmd/symph-term/main.go new file mode 100644 index 0000000..659f24b --- /dev/null +++ b/cmd/symph-term/main.go @@ -0,0 +1,140 @@ +package main + +import ( + "fmt" + "os" + "time" + + "symph/audio" + "symph/game" + "symph/input" + "symph/parameter" + "symph/render" + + "github.com/lixenwraith/terminal" +) + +func main() { + + // Terminal init + term := terminal.New() + if err := term.Init(); err != nil { + fmt.Fprintln(os.Stderr, "terminal init:", err) + os.Exit(1) + } + defer term.Fini() + + // Game init + state := game.New() + + // Audio init (silent fallback when backend unavailable) + sfx := audio.NewMuter(audio.NewEngine()) + defer sfx.Close() + + // Render init + width, height := term.Size() + renderer := render.NewRenderer(term, width, height, state) + minW, minH := render.MinSize() + + // Ticker wakes the event loop for UI frames (~60fps) + // The engine strictly owns beat quantization via Update(); frontend just pushes time forward + ticker := time.NewTicker(parameter.GameRenderUpdate) + defer ticker.Stop() + + go func() { + for range ticker.C { + term.PostEvent(terminal.Event{Type: terminal.EventKey, Key: terminal.KeyNone}) + } + }() + + renderer.Render(time.Now()) + + for { + + ev := term.PollEvent() + + switch ev.Type { + + case terminal.EventClosed, terminal.EventError: + return + + case terminal.EventKey: + + // Quit on Escape, Ctrl+C, or 'q' + if ev.Key == terminal.KeyEscape || ev.Key == terminal.KeyCtrlC || ev.Rune == 'q' || ev.Rune == 'Q' { + return + } + + // Toggle mute with Ctrl+S + if ev.Key == terminal.KeyCtrlS { + renderer.Muted = sfx.Toggle() + } + + now := time.Now() + + // Viewport gate. Below the minimum the field is unrenderable; + // freeze the simulation clock so no beats elapse behind the notice + if w, h := term.Size(); w < minW || h < minH { + state.Pause(now) + renderer.Render(now) + continue + } + state.Resume(now) + + // Real key: translate and dispatch with strict temporal timestamp + if k := eventToKey(ev); k != input.KeyNone { + state.Dispatch(input.Translate(k), now) + } + + // Engine time step, event drain, render cycle every iteration + // (Synthetic tick falls through to here naturally) + state.Update(now) + for _, ge := range state.DrainEvents() { + sfx.Play(audio.MapGameEvent(ge)) + } + renderer.Render(now) + + case terminal.EventResize: + + renderer.Resize(ev.Width, ev.Height) + now := time.Now() + if ev.Width < minW || ev.Height < minH { + state.Pause(now) + } else { + state.Resume(now) + } + renderer.Render(time.Now()) + + } + } +} + +// eventToKey translates a terminal key event to a platform-neutral input.Key +func eventToKey(ev terminal.Event) input.Key { + if ev.Key == terminal.KeyEnter { + return input.KeyEnter + } + switch ev.Rune { + case 'h', 'H': + return input.KeyH + case 'j', 'J': + return input.KeyJ + case 'k', 'K': + return input.KeyK + case 'l', 'L': + return input.KeyL + case ';': + return input.KeySemicolon + case 'a', 'A': + return input.KeyA + case 's', 'S': + return input.KeyS + case 'd', 'D': + return input.KeyD + case 'f', 'F': + return input.KeyF + case ' ': + return input.KeySpace + } + return input.KeyNone +} diff --git a/doc/design.md b/doc/design.md new file mode 100644 index 0000000..0a3f2d9 --- /dev/null +++ b/doc/design.md @@ -0,0 +1,239 @@ +# Symph: Architecture & Design Specification + +## System Abstract + +Symph is a rhythm-driven grid traversal game. Real-time spatial mechanics are +synchronized against a quantized temporal progression. The engine decouples +hardware-frequency rendering and input polling from macro-frequency musical +progression, exposing a deterministic, side-effect-free state machine to +Terminal, Raylib, and Mobile frontends. + +--- + +## 1. Domain Nomenclature + +* **Symphony** *(planned)*: collection of Songs. +* **Song**: master temporal container (Z-axis). Slice of `Chord`. +* **Chord**: one slice in time. A 3x3 matrix of `Note`. +* **Note**: atomic element at `(Y, X)`, carrying a `Value`. +* **Symphace** *(planned)*: bounded environment surrounding the grid. + +--- + +## 2. Coordinate System & Displacement + +* **PIZ**: temporal depth index. Advances on the beat. +* **PIY, PIX**: grid coordinates, bounded to `[0, 2]`. Spawn/rest anchor `(1, 1)`. + +Movement is decoupled from `PIZ`. + +* **X (lanes)**: persistent. The player holds the lane until moved again. +* **Y (rows)**: transient. A row shift arms `ResetTime` at + `PlayerRowResetChords * PlayDeltaZ`, rescaled on every tempo change, so the + landing chord is invariant across levels and across a Boost. On expiry, Y + collapses to `BaseIndexY` and the landing cell is resolved — returning into a + wall is fatal. + A manual move back to the base row cancels the timer (duck out of a jump). +* Cells resolve on three triggers: beat arrival, player movement, row-reset + landing. + +--- + +## 3. Phases + +`PhasePlaying` → `PhaseLevelClear` (song exhausted, `TransitionDuration` +interlude, then next level) → `PhasePlaying`. +`PhaseGameOver` on wall contact; `ActionConfirm` restarts from level 1. +Input is inert during `PhaseLevelClear`. `Pause`/`Resume` shift every absolute +timestamp forward by the paused span, so no beats or row-reset expiries +accumulate behind a pause (used by the terminal viewport gate). +A deadline already lapsed when the dead span opened is cleared, not shifted: +dead time never revives a Boost, extends a row reset, or replays a burn-out. + +--- + +## 4. Chronometry & Concurrency + +Two isolated time domains: + +* **Macro (beat)**: `PlayDeltaZ`. Elapsed spans are consumed in whole ticks; + `LastPlayedTime` advances by whole intervals, never by `now` — no drift. +* **Micro (render/input)**: host framerate (`GameRenderUpdate`, ~60Hz). + Resolves sub-beat state: row-reset countdown, blink phase, morph colors. + +### Tempo + +* `BaseDeltaZ` — level-derived: `GameDeltaZBase` (500ms), shortened by + `GameDeltaZStep` per level down to `GameDeltaZMin`. +* `deltaAt(t)` — `BaseDeltaZ / BoostSpeedFactor` while a Boost runs at `t`, else + `BaseDeltaZ`. The only place tempo is scaled; a slow effect divides here. +* `PlayDeltaZ` — the interval of the beat **in flight**, fixed at that beat's + start. A Boost taken mid-chord lands on the next boundary: the chord under the + player never shortens beneath them. +* `Update` re-reads the interval at each boundary, so a Boost expiring inside a + frame is exact. `TimeToChordDistance` replays the recurrence forward, so every + displayed countdown matches the simulation. + +### Pipeline + +Single-threaded polling loop. No mutexes, no channels over game state. + +* A `time.Ticker` injects synthetic wake-up events (`terminal.KeyNone`) into + the frontend event queue; raylib polls its own frame loop. +* Per iteration: translate native key → `input.Key` → `game.Action`, + `Dispatch(action, now)`, `Update(now)`, `DrainEvents()`, render. +* Host controls bypass `input`/`game`: quit (Esc / Ctrl+C / `q`) and the audio + gate (Ctrl+S) are handled in `cmd/`. They must work while paused and in every + phase, which `Dispatch` does not allow. `audio.Muter` wraps an `Engine` and + drops `Play` while muted; the header carries `MUTE`. +* The engine emits no side-effects. Gameplay occurrences are queued as `Event` + enums and drained once per iteration by the frontend, which maps them to + audio. + +--- + +## 5. Values & Items + +Every `Value` has one row in `types.valueSpecs` — `{Name, Glyph, Polarity}` — +which is the single source of truth for kind identity. `Glyph` is the pattern +authoring rune *and* the ASCII display fallback; `init` asserts completeness, +ASCII range and uniqueness at process start. `Polarity` is a table lookup, not +a switch. + +| Value | Glyph | Polarity | Behavior | +|---|---|---|---| +| `ValueEnergy` | `*` | positive | +1 energy, consumed on contact | +| `ValueMagnet` | `M` | positive | consumed; sweeps **every** Energy note within `PositionLookaheadWindow` chords, at all 9 grid positions | +| `ValueShield` | `S` | positive | consumed; arms `ShieldCharges` absorb charges. No expiry | +| `ValueBoost` | `B` | positive | consumed; arms `BoostDuration` of `BoostSpeedFactor`× tempo. Refreshes, does not stack | +| `ValueWall` | `#` | negative | fatal, unless a charge absorbs it | + +Adding a kind is three rows: `types.valueSpecs` (identity), +`game.resolvers` (contact behavior), `render.valueVisuals` (color ramp). +No switch is edited. `parseChord` rejects any pattern authoring a kind with no +resolver, so an unresolved kind can never be walked through. + +### 5.1 Status + +`game.Status` holds the persistent player effects. Both survive a level change; +both are cleared on restart, never on death-free level transition. + +* **Shield** — a charge count. `hitHazard` spends one charge, **destroys the + note**, records it in the consume ring, and play continues: the lookahead, + ring and timer all re-derive from the cleared cell, giving the player exactly + one beat to leave the lane. Because absorption sits in `resolveCell`, it + covers all three resolution triggers (beat arrival, movement, row-reset + landing) with no second code path. +* **Boost** — an absolute deadline. Read only at beat boundaries. + +`Status.shift(at, d)` moves **live** deadlines across a pause and across the +level-clear interlude, so a Boost taken at a song's tail is not burned by the +transition wave. `at` is the instant the dead span opened; a deadline already +lapsed at `at` is cleared, never revived. `restart` zeroes the whole `Status`. + +--- + +## 6. Lookahead Model + +`ScanTile(y, x, window)` returns a `TileLookahead` for one grid position, +allocation-free, in a single pass: + +* `Cells` — raw `Value` per chord distance (0 = current chord). +* `WallMask` — `uint16` bitmask, bit *d* set when distance *d* holds a wall. + `TileLookaheadMaxWindow` = 16 is the hard ceiling on the window. +* `Positive` / `Negative` — nearest occurrence per polarity class. +* `NearestWall()` derives the leading wall segment (distance, run length, + reopen gap, trap flag, open-ended flag) from the bitmask. + +All proximity, run, gap and trap **policy** derives from the bitmask in the +frontend. The engine holds no presentation rules. + +--- + +## 7. Presentation Contract (terminal) + +Tile, 12x5: + +* **border** — ring colored by nearest-wall band (`WallBandCount` bands of + `WallBandSize` chords). Fill is binary: solid only when a wall occupies the + current chord. Urgency rides on color, not density. +* **header row** — `LEVEL` left, active status (shield glyph, boost countdown) + centered, `ENERGY` right. +* **row 1** — wall timer. `▼` chords until the lane shuts, `▲` until it + reopens; `+` open-ended run, `!` trap gap (`<= TrapGapMax`). +* **row 2** — distance row: nearest positive and nearest negative occurrence, + each glyph + chord-distance. The tile(s) whose distance equals the + **grid-wide** minimum for that polarity blink their group + (positive: white/green, negative: yellow/red, `RenderBlinkPeriod`). Ties are + not broken — every tile at the minimum blinks. Distance 0 counts. +* **row 3** — lookahead strip: one cell per chord, leftmost = now. + Distance is carried positionally, so items draw at arrived color; + walls keep the band gradient so ring, timer and strip agree. + A cell emptied by consumption burns out in place: + white flash, then glyph collapse into the rail cell. + Grid-scope scan (`render.GridScan`) and the burn-out projection + (`render.FadeGrid`) are shared by both frontends; policy lives in `render`. +* **background** — player position fill; brightened while a charge is held, so + the shield reads in the field rather than only in the HUD. + +Item color ramps use high-luminance endpoints at both ends: the distance row +draws items at their true distance, and a dark distant endpoint is unreadable +against the black field. + +**Raylib**: same contract, different medium. Layout derives from two gap +fractions — `gapFracX` (usable width) and `gapFracY` (tile height). The row gap +hosts the reset funnel, so the funnel font is sized against it (`funnelFrac`, +floored at `funnelFontMin`). + +--- + +## 8. Package Topology + +Unidirectional dependency flow (supports CGO/gomobile). + +* `types/` — dependency-free structures (`Song`, `Chord`, `Note`, + `PlayerState`) and the value registry (`valueSpecs`: identity, glyph, + polarity). Breaks import cycles. +* `parameter/` — compile-time constants: grid bounds, timings, glyphs, tile + geometry, lookahead window. Several invariants are enforced by `const _` + assertions (window vs bitmask width, window vs band span, density glyphs vs + band count). +* `game/` — pure state engine. Contact behavior is the `resolvers` table keyed + on `Value`; `Status` holds persistent item effects. +* `input/` — native key → platform-neutral `Key` → `game.Action`. The + gomobile path bypasses this package and produces `Action` directly. +* `render/` — appearance is the `valueVisuals` table keyed on `Value`. + `TileGlyphASCII` is the atlas-limited substitution for raylib. + `render/raylib/` shares the `render` caching pipeline and is up-to-date with the `TileLookahead` API. +* `audio/` — fire-and-forget effects. PCM synthesized at startup; PipeWire + backend on Linux, silent `nullEngine` elsewhere or when `pw-play` is absent. + `Muter` composes an `Engine` with the host mute gate. +* `cmd/` — hosts (`symph-term`, `symph-raylib`): wiring, event poller, ticker. + +--- + +## 9. Known Divergences + +* Raylib substitutes ASCII for the Unicode glyph set (default font atlas is + 32..126) and draws walls as rectangles rather than density glyphs. + Load a font with an explicit codepoint set for glyph parity. +* `audio` spawns one `pw-play` process per effect; per-spawn stream connect + costs tens of ms. Replace with a persistent stream before latency matters. +* FreeBSD audio backend (sndio/OSS) unimplemented — falls through to + `nullEngine`. +* **Boost expiry inside a wall band is the primary death mode.** `resolveCell` + fires on beat arrival, so a boosted player standing in a wall when the + deadline lapses dies on the next beat — one chord (>= 150ms) to leave the + lane. The HUD blink is the only warning. Intentional. +* **Walls still render lethal while boosted.** Ring, timer and strip are + unaware of immunity; recoloring requires threading `Status` through + `WallBandColor` / `TileGlyph` / `WallTimerText`. Deferred. +* **Boost shares the warm end of the wall band palette** (`PaleLemon → Gold` vs + `Yellow → BrightRed`). Glyph and fixed distance-row column disambiguate. +* `EventDeflect` fires once per walled chord traversed; with the `pw-play` + backend that is one process spawn per chord during a boosted wall run. +* `ItemFadeDuration` (140ms) clears the boosted beat floor + (`GameDeltaZMin/BoostSpeedFactor` = 150ms) by 10ms. Lower it to ~100ms before + touching either constant. +* `TimerText` clamps at 9.9s, so a fresh 10s Boost reads `9.9` for its first + 100ms. diff --git a/game/action.go b/game/action.go new file mode 100644 index 0000000..e22f532 --- /dev/null +++ b/game/action.go @@ -0,0 +1,54 @@ +package game + +import ( + "time" +) + +// Action is a platform-neutral semantic input action, dispatched to the +// engine independent of the originating input device (keyboard, touch, etc) +type Action uint8 + +const ( + ActionNone Action = iota + ActionMoveLeft // grid: dx=-1 + ActionMoveUp // grid: dy=-1 + ActionMoveDown // grid: dy=+1 + ActionMoveRight // grid: dx=+1 + + // Reserved, behavior defined later + ActionStubSemicolon + ActionStubA + ActionStubS + ActionStubD + ActionStubF + ActionSpace + ActionConfirm +) + +// Dispatch applies a single Action to the game state, gated by phase. +// Requires current time to timestamp temporal state shifts +func (gs *GameState) Dispatch(a Action, now time.Time) { + if gs.Paused { + return + } + switch gs.Phase { + case PhaseGameOver: + if a == ActionConfirm { + gs.restart(now) + } + return + case PhaseLevelClear: + return // input inert during transition + } + + switch a { + case ActionMoveLeft: + gs.MovePlayer(0, -1, now) + case ActionMoveUp: + gs.MovePlayer(-1, 0, now) + case ActionMoveDown: + gs.MovePlayer(1, 0, now) + case ActionMoveRight: + gs.MovePlayer(0, 1, now) + } +} diff --git a/game/game.go b/game/game.go new file mode 100644 index 0000000..a87628a --- /dev/null +++ b/game/game.go @@ -0,0 +1,563 @@ +package game + +import ( + "math/bits" + "time" + + "symph/parameter" + "symph/types" +) + +// --- State machine --- + +// Phase is the top-level game state machine +type Phase uint8 + +const ( + PhasePlaying Phase = iota + PhaseLevelClear // song finished, transition interlude running + PhaseGameOver // wall hit, awaiting ActionConfirm restart +) + +// Event is a gameplay occurrence emitted for frontend consumption (audio, +// haptics). The engine performs no side-effects itself +type Event uint8 + +const ( + EventNone Event = iota + EventPickup + EventMagnet + EventShield + EventBoost + EventDeflect + EventShieldBreak + EventHitWall + EventMoveRow + EventMoveLane + EventLevelClear + EventLevelStart +) + +// GameState represents the complete game state +type GameState struct { + Song types.Song // All chords in the song + LastPlayedTime time.Time // When we last played a note + PlayerState types.PlayerState // Current player position + BaseDeltaZ time.Duration // Level-derived inter-chord interval + PlayDeltaZ time.Duration // Interval of the beat in flight, fixed at its start + CurrentPlayIndexZ int // Current play index (which chord we're on) + + Phase Phase + PhaseStart time.Time // Entry time of current phase; drives transition timing/FX + Level int + Energy int + Status Status // Persistent item effects; survives a level change + + events []Event + + // consumed-note ring; frontends animate the burn-out from it + consumed [parameter.ConsumeRingSize]Consume + consumeAt int + + Paused bool + pausedAt time.Time +} + +// Consume records a positive note removed from the field in absolute song coordinates. +// The frontend is responsible for defining the duration and appearance of the burn-out. +type Consume struct { + At time.Time + Z int // absolute chord index + Y, X int // grid position + Value types.Value // ValueNone marks an unused ring slot +} + +// --- Lifecycle --- + +// New creates a game state and starts level 1 +func New() *GameState { + gs := &GameState{} + gs.startLevel(1, time.Now()) + return gs +} + +// recordConsume writes into the ring, overwriting the oldest entry +func (gs *GameState) recordConsume(v types.Value, z, y, x int, now time.Time) { + gs.consumed[gs.consumeAt] = Consume{At: now, Z: z, Y: y, X: x, Value: v} + gs.consumeAt = (gs.consumeAt + 1) % len(gs.consumed) +} + +// Consumed exposes the ring for frontend burn-out projection. Slots with ValueNone are unused. +// The backing array is overwritten in place; callers must not retain the slice across iterations +func (gs *GameState) Consumed() []Consume { return gs.consumed[:] } + +// Pause freezes the simulation clock. Idempotent +func (gs *GameState) Pause(now time.Time) { + if gs.Paused { + return + } + gs.Paused, gs.pausedAt = true, now +} + +// Resume shifts every absolute timestamp forward by the paused span, so no +// beats or row-reset expiries accumulate behind the pause. Idempotent +func (gs *GameState) Resume(now time.Time) { + if !gs.Paused { + return + } + d := max(now.Sub(gs.pausedAt), 0) + gs.LastPlayedTime = gs.LastPlayedTime.Add(d) + gs.PhaseStart = gs.PhaseStart.Add(d) + // Deadlines lapsed before the freeze are cleared, not carried forward + gs.Status.shift(gs.pausedAt, d) + if gs.PlayerState.ResetTime.After(gs.pausedAt) { + gs.PlayerState.ResetTime = gs.PlayerState.ResetTime.Add(d) + } + for i := range gs.consumed { + c := &gs.consumed[i] + if c.Value == types.ValueNone { + continue + } + // Burn-out finished before the freeze must not replay + if gs.pausedAt.Sub(c.At) >= parameter.ItemFadeDuration { + c.Value = types.ValueNone + continue + } + c.At = c.At.Add(d) + } + gs.Paused = false +} + +// startLevel (re)initializes song, player, and timing for the given level. +// Status is deliberately untouched: item effects cross the level boundary +func (gs *GameState) startLevel(level int, now time.Time) { + gs.Level = level + gs.Song = *newSong(level) + gs.CurrentPlayIndexZ = 0 + gs.consumed, gs.consumeAt = [parameter.ConsumeRingSize]Consume{}, 0 + gs.PlayerState = types.PlayerState{ + PlayIndexY: parameter.GamePlayIndexYStart, + PlayIndexX: parameter.GamePlayIndexXStart, + BaseIndexY: parameter.GamePlayIndexYStart, + } + gs.BaseDeltaZ = max( + parameter.GameDeltaZBase-time.Duration(level-1)*parameter.GameDeltaZStep, + parameter.GameDeltaZMin, + ) + gs.LastPlayedTime = now + gs.PlayDeltaZ = gs.deltaAt(now) + gs.PhaseStart = now + gs.Phase = PhasePlaying + gs.emit(EventLevelStart) +} + +// deltaAt returns the inter-chord interval for a beat starting at t: the +// level-derived base, divided by BoostSpeedFactor while a Boost is active. +// Update and TimeToChordDistance share this recurrence, so every displayed +// countdown matches the simulation exactly, including across a Boost expiry +func (gs *GameState) deltaAt(t time.Time) time.Duration { + d := gs.BaseDeltaZ + if gs.Status.Boosted(t) { + d /= parameter.BoostSpeedFactor + } + return d +} + +// rescaleReset holds a pending row displacement at a fixed chord length across +// a tempo change: the remaining span is scaled by the interval ratio, so a jump +// armed for PlayerRowResetChords chords lands PlayerRowResetChords chords later +// whatever the Boost does in between. Called only at beat boundaries, so the +// beat domain and the displacement domain run off one clock. +// rem <= PlayerRowResetChords*GameDeltaZBase; the product cannot overflow int64 +func (gs *GameState) rescaleReset(at time.Time, oldD, newD time.Duration) { + if oldD == newD || gs.PlayerState.ResetTime.IsZero() { + return + } + rem := gs.PlayerState.ResetTime.Sub(at) + if rem <= 0 { + return + } + gs.PlayerState.ResetTime = at.Add(rem * newD / oldD) +} + +// restart resets progression after game over +func (gs *GameState) restart(now time.Time) { + gs.Energy = 0 + gs.Status = Status{} // Reset effects on game restart + gs.startLevel(1, now) +} + +// --- Simulation --- + +// Update advances time-based game state +func (gs *GameState) Update(now time.Time) { + if gs.Paused { + return + } + switch gs.Phase { + case PhaseLevelClear: + if el := now.Sub(gs.PhaseStart); el >= parameter.TransitionDuration { + // The interlude is dead time — hold the effect deadlines across it + gs.Status.shift(gs.PhaseStart, el) // Clamp at the interlude entry + gs.startLevel(gs.Level+1, now) + } + return + case PhaseGameOver: + return + } + + // 1. Row displacement expiry (sub-beat domain): collapse Y to base row. + // Landing resolves against the current chord — returning into a wall kills + if !gs.PlayerState.ResetTime.IsZero() && now.After(gs.PlayerState.ResetTime) { + gs.PlayerState.PlayIndexY = gs.PlayerState.BaseIndexY + gs.PlayerState.ResetTime = time.Time{} + gs.resolveCell(now) + if gs.Phase != PhasePlaying { + return + } + } + + // 2. Song ticks (beat domain). + // One beat at a time. The interval is fixed at each beat's start, so a + // Boost taken mid-chord lands on the next boundary (the chord in flight + // never shortens under the player) and an expiry inside a frame is exact. + // LastPlayedTime advances only by whole intervals — no drift + for { + next := gs.LastPlayedTime.Add(gs.PlayDeltaZ) + if now.Before(next) { + return + } + if gs.CurrentPlayIndexZ >= len(gs.Song.Chords)-1 { + gs.Phase = PhaseLevelClear + gs.PhaseStart = now + gs.emit(EventLevelClear) + return + } + gs.LastPlayedTime = next + + // Tempo transitions land on beat boundaries only; a pending row + // displacement is rescaled with them + d := gs.deltaAt(next) + gs.rescaleReset(next, gs.PlayDeltaZ, d) + gs.PlayDeltaZ = d + + gs.CurrentPlayIndexZ++ + gs.resolveCell(now) + if gs.Phase != PhasePlaying { + return + } + } +} + +// resolver applies a Value's contact behavior. n aliases the note inside the +// Song: consumption writes through it +type resolver func(gs *GameState, n *types.Note, z, y, x int, now time.Time) + +// resolvers is indexed by Value. A nil row is inert — the kind renders and +// carries polarity, but contact does nothing. Adding a kind means adding a row +// here and a row in types.valueSpecs. The generator rejects patterns that +// author an unresolved kind, so inertness is never reachable in play +var resolvers = [types.ValueCount]resolver{ + types.ValueEnergy: (*GameState).takeEnergy, + types.ValueMagnet: (*GameState).takeMagnet, + types.ValueShield: (*GameState).takeShield, + types.ValueBoost: (*GameState).takeBoost, + types.ValueWall: (*GameState).hitHazard, +} + +// resolveCell evaluates content at the player position of the current chord. +// Called on beat arrival, on player movement, and on row-reset landing — so +// Shield absorption covers all three without a second code path +func (gs *GameState) resolveCell(now time.Time) { + y, x, z := gs.PlayerState.PlayIndexY, gs.PlayerState.PlayIndexX, gs.CurrentPlayIndexZ + n := &gs.Song.Chords[z].Notes[y][x] + if r := resolvers[n.Value]; r != nil { + r(gs, n, z, y, x, now) + } +} + +func (gs *GameState) takeEnergy(n *types.Note, z, y, x int, now time.Time) { + gs.Energy++ + n.Value = types.ValueNone + gs.recordConsume(types.ValueEnergy, z, y, x, now) + gs.emit(EventPickup) +} + +// takeMagnet sweeps every Energy note inside the lookahead window at every grid +// position — the collected set is exactly what the tile strips display +func (gs *GameState) takeMagnet(n *types.Note, z, y, x int, now time.Time) { + n.Value = types.ValueNone + gs.recordConsume(types.ValueMagnet, z, y, x, now) + gs.collectWindow(parameter.PositionLookaheadWindow, now) + gs.emit(EventMagnet) +} + +// takeShield arms the absorb charge. A pickup while shielded re-arms and is +// still consumed +func (gs *GameState) takeShield(n *types.Note, z, y, x int, now time.Time) { + gs.Status.grantShield() + n.Value = types.ValueNone + gs.recordConsume(types.ValueShield, z, y, x, now) + gs.emit(EventShield) +} + +// takeBoost re-arms the tempo deadline. The beat in flight keeps the interval +// it started with; the shortened interval applies from the next boundary +func (gs *GameState) takeBoost(n *types.Note, z, y, x int, now time.Time) { + gs.Status.grantBoost(now) + n.Value = types.ValueNone + gs.recordConsume(types.ValueBoost, z, y, x, now) + gs.emit(EventBoost) +} + +// hitHazard resolves contact with a negative Value. Precedence: +// +// Boost — immune. The note survives: the field, ring, timer and lookahead +// stay truthful, and no charge is spent +// Shield — one charge absorbs the contact and destroys the note. Play +// continues; everything re-derives from the cleared cell, giving the +// player one beat to leave the lane +// neither — the run ends +// +// Non-fatal negatives (Drain) branch here once implemented +func (gs *GameState) hitHazard(n *types.Note, z, y, x int, now time.Time) { + if gs.Status.Boosted(now) { + gs.emit(EventDeflect) + return + } + if gs.Status.absorb() { + v := n.Value + n.Value = types.ValueNone + gs.recordConsume(v, z, y, x, now) + gs.emit(EventShieldBreak) + return + } + gs.Phase = PhaseGameOver + gs.PhaseStart = now + gs.emit(EventHitWall) +} + +// MovePlayer applies a grid delta. Lane (X) shifts persist; row (Y) shifts +// arm the return-to-base-row timer. Manual return to base row cancels the +// timer (duck out of a jump). Movement into content resolves immediately +func (gs *GameState) MovePlayer(dy, dx int, now time.Time) { + p := &gs.PlayerState + moved := false + + if dy != 0 { + if ny := p.PlayIndexY + dy; ny >= 0 && ny < parameter.GamePlayIndexYMax { + p.PlayIndexY = ny + moved = true + gs.emit(EventMoveRow) + if ny == p.BaseIndexY { + p.ResetTime = time.Time{} + } else { + // Based on beat interval in flight. rescaleReset holds the chord length across any tempo change before expiry. + p.ResetTime = now.Add(parameter.PlayerRowResetChords * gs.PlayDeltaZ) + } + } + } + + if dx != 0 { + if nx := p.PlayIndexX + dx; nx >= 0 && nx < parameter.GamePlayIndexXMax { + p.PlayIndexX = nx + moved = true + gs.emit(EventMoveLane) + } + } + + if moved { + gs.resolveCell(now) + } +} + +// --- Event queue --- + +// emit queues a gameplay event for frontend drain +func (gs *GameState) emit(e Event) { + gs.events = append(gs.events, e) +} + +// DrainEvents returns and clears queued events. Single consumer: call once per +// loop iteration from the owning goroutine, consume before next Dispatch/Update +func (gs *GameState) DrainEvents() []Event { + if len(gs.events) == 0 { + return nil + } + evs := gs.events + gs.events = nil + return evs +} + +// --- Read-only queries (renderer/audio consumption) --- + +// GetNoteAtPlayerPosition returns the note at current player position +func (gs *GameState) GetNoteAtPlayerPosition() types.Note { + return gs.Song.Chords[gs.CurrentPlayIndexZ].Notes[gs.PlayerState.PlayIndexY][gs.PlayerState.PlayIndexX] +} + +// TimeToChordDistance converts a chord-distance to wall-clock time remaining. +// Distance 0 arrived at LastPlayedTime, so its result is <= 0. LastPlayedTime +// is frozen outside PhasePlaying; callers gate on Phase +func (gs *GameState) TimeToChordDistance(distance int, now time.Time) time.Duration { + t, d := gs.LastPlayedTime, gs.PlayDeltaZ + for range distance { + t = t.Add(d) + d = gs.deltaAt(t) + } + return t.Sub(now) +} + +// TileLookaheadMaxWindow is the wall bitmask width +const TileLookaheadMaxWindow = 16 + +// Compile-time: the scanned window must fit the uint16 wall bitmask +const _ = uint(TileLookaheadMaxWindow - parameter.PositionLookaheadWindow) + +// ItemStat reports the nearest occurrence of one polarity class at a grid +// position. Occurrence counts are not carried: the strip shows every occurrence positionally +type ItemStat struct { + Value types.Value // kind of the nearest occurrence + Distance int // chords to it; -1 when absent +} + +// TileLookahead summarizes one grid position over the observation window. +// Walls are exposed as a raw bitmask; all proximity/run/gap policy derives +// from it, keeping presentation rules out of the engine +type TileLookahead struct { + // Cells holds the raw content per chord distance (index 0 = current chord). + // Valid for indices < Window + Cells [TileLookaheadMaxWindow]types.Value + + WallMask uint16 // bit d set when the chord at distance d holds a wall + Window int // scanned span; clamped to song end + + // Fixed polarity slots replace the nearest-first kind list + Positive ItemStat // nearest Energy/Magnet/Boost/Shield + Negative ItemStat // nearest Wall/Spike/Drain/enemy +} + +// WallDistance returns the chord-distance to the nearest wall, -1 if none +func (tl TileLookahead) WallDistance() int { + if tl.WallMask == 0 { + return -1 + } + return bits.TrailingZeros16(tl.WallMask) +} + +// WallSegment describes the nearest wall band at a grid position: +// where it starts, how long it shuts the lane, the reopen gap that follows, +// and whether that gap is closed again inside the window +type WallSegment struct { + Distance int // chords until the lane shuts; -1 when no wall in window + Run int // contiguous walled chords from Distance + Gap int // open chords after the run + Next bool // another wall closes Gap within the window + Open bool // the run reaches the window edge; Run is a lower bound +} + +// NearestWall resolves the leading wall segment from the bitmask. All +// proximity/run/gap/trap policy derives from this; the engine holds no +// presentation rules +func (tl TileLookahead) NearestWall() WallSegment { + if tl.WallMask == 0 { + return WallSegment{Distance: -1} + } + d := bits.TrailingZeros16(tl.WallMask) + rest := tl.WallMask >> uint(d) // bit0 set by construction + + seg := WallSegment{Distance: d} + seg.Run = min(bits.TrailingZeros16(^rest), tl.Window-d) + + if d+seg.Run >= tl.Window { + seg.Open = true + return seg + } + if rest >>= uint(seg.Run); rest == 0 { + seg.Gap = tl.Window - d - seg.Run + return seg + } + seg.Gap = bits.TrailingZeros16(rest) + seg.Next = true + return seg +} + +// WallRun returns the length of the contiguous wall run starting at distance 0 +// (0 when the current chord is open). Equals Window when the run fills the +// window — the lane's reopen point lies beyond observation +func (tl TileLookahead) WallRun() int { + return min(bits.TrailingZeros16(^tl.WallMask), tl.Window) +} + +// WallGapAfterRun returns the open-chord span following the leading wall run +// and whether another wall closes that gap within the window +func (tl TileLookahead) WallGapAfterRun() (gap int, next bool) { + run := tl.WallRun() + rest := tl.WallMask >> uint(run) + if rest == 0 { + return tl.Window - run, false + } + return bits.TrailingZeros16(rest), true +} + +// ScanTile summarizes grid position (y,x) across the specified window of chords +// starting at CurrentPlayIndexZ (where distance 0 is the current chord). +func (gs *GameState) ScanTile(y, x, window int) TileLookahead { + window = min(window, TileLookaheadMaxWindow) + + // Clamp Window to the chords that exist; run/gap policy reads Window as + // the observation horizon + if span := len(gs.Song.Chords) - gs.CurrentPlayIndexZ; span < window { + window = max(span, 0) + } + + tl := TileLookahead{ + Window: window, + Positive: ItemStat{Distance: -1}, + Negative: ItemStat{Distance: -1}, + } + + for d := range window { + v := gs.Song.Chords[gs.CurrentPlayIndexZ+d].Notes[y][x].Value + tl.Cells[d] = v + + if v == types.ValueWall { + tl.WallMask |= 1 << uint(d) + } + + switch v.Polarity() { + case types.PolarityPositive: + if tl.Positive.Distance < 0 { + tl.Positive = ItemStat{Value: v, Distance: d} + } + case types.PolarityNegative: + if tl.Negative.Distance < 0 { + tl.Negative = ItemStat{Value: v, Distance: d} + } + } + } + + return tl +} + +// --- Legacy queries --- + +// collectWindow consumes every Energy note within `window` chords of the +// current chord, across all grid positions. Walls are untouched: the sweep +// grants reach, not immunity. One event is emitted by the caller — per-note +// emission would flood the frontend drain with a burst of identical effects +func (gs *GameState) collectWindow(window int, now time.Time) { + end := min(gs.CurrentPlayIndexZ+window, len(gs.Song.Chords)) + for z := gs.CurrentPlayIndexZ; z < end; z++ { + notes := &gs.Song.Chords[z].Notes + for y := range notes { + for x := range notes[y] { + if notes[y][x].Value == types.ValueEnergy { + notes[y][x].Value = types.ValueNone + gs.Energy++ + gs.recordConsume(types.ValueEnergy, z, y, x, now) + } + } + } + } +} diff --git a/game/generator.go b/game/generator.go new file mode 100644 index 0000000..7f80869 --- /dev/null +++ b/game/generator.go @@ -0,0 +1,258 @@ +package game + +import ( + "fmt" + "math/rand/v2" + "strings" + + "symph/parameter" + "symph/types" +) + +// patternDef is an authored gameplay segment. Patterns are hand-built to stay +// fair: every chord leaves a reachable safe cell, hazards are telegraphed by +// preceding pickups. minLevel gates entry into the selection pool +type patternDef struct { + minLevel int + chords []string +} + +var patternDefs = []patternDef{ + // --- Level 1: pickup lines, no hazards --- + {1, []string{ // center run + "...|.*.|...", + "...|.*.|...", + "...|.*.|...", + }}, + {1, []string{ // left lane run + "...|*..|...", + "...|*..|...", + "...|*..|...", + }}, + {1, []string{ // right lane run + "...|..*|...", + "...|..*|...", + "...|..*|...", + }}, + {1, []string{ // jump arc + "...|.*.|...", + ".*.|...|...", + ".*.|...|...", + "...|.*.|...", + }}, + {1, []string{ // slide dip + "...|.*.|...", + "...|...|.*.", + "...|...|.*.", + "...|.*.|...", + }}, + {1, []string{ // lane sweep L→R + "...|*..|...", + "...|.*.|...", + "...|..*|...", + }}, + + // --- Level 2: single telegraphed obstacles --- + {2, []string{ // hop wall: energy above hazard + "...|.*.|...", + ".*.|.#.|...", + "...|.*.|...", + }}, + {2, []string{ // duck wall: energy below hazard + "...|.*.|...", + "...|.#.|.*.", + "...|.*.|...", + }}, + {2, []string{ // side pinch: hold center + "...|.*.|...", + "...|#.#|...", + "...|#.#|...", + "...|.*.|...", + }}, + {2, []string{ // forced left lane + "...|*..|...", + "...|*.#|...", + "...|*.#|...", + "...|.*.|...", + }}, + {2, []string{ // magnet lure + "...|.*.|...", + "...|.M.|...", + "*.*|...|*.*", + "...|.*.|...", + }}, + {2, []string{ // shield cache: armor, then the wall that spends it + "...|.*.|...", + "...|.S.|...", + "...|.#.|...", + "...|.*.|...", + }}, + {2, []string{ // boost dash — tempo alone, no hazards + "...|.B.|...", + "...|.*.|...", + "...|*..|...", + "...|..*|...", + "...|*..|...", + "...|.*.|...", + }}, + + // --- Level 3+: combinations --- + {3, []string{ // hop then duck + "...|.*.|...", + ".*.|.#.|...", + "...|.*.|...", + "...|.#.|.*.", + "...|.*.|...", + }}, + {3, []string{ // tunnel: mid row only (Down cancels a stray jump in time) + "...|.*.|...", + "###|.S.|###", + "###|.*.|###", + "###|.*.|###", + "...|.*.|...", + }}, + {3, []string{ // top weave R→L over walls (single jump spans it at base PDZ) + "...|.*.|...", + "..*|.##|...", + ".*.|#.#|...", + "*..|##.|...", + "...|.*.|...", + }}, + {3, []string{ // pillar dodge: center blocked, side lanes rewarded + "...|.*.|...", + ".#.|*#*|.#.", + "...|.*.|...", + }}, + {3, []string{ // trap lane: center reopens for a single chord, then shuts again. Exercises the TimerTrapSuffix marker + "...|.*.|...", + "...|.#.|...", + "...|.#.|...", + ".*.|...|...", + "...|.#.|...", + "...|.#.|...", + "...|.*.|...", + }}, + {3, []string{ // boost run: hazard-free corridor — the double tempo turns the lane hops into the skill test + "...|.B.|...", + "...|.*.|...", + "...|*.*|...", + "...|.*.|...", + "...|*.*|...", + "...|.*.|...", + }}, + {3, []string{ // boost gauntlet — mid-center is open at every chord + "...|.B.|...", + "...|.*.|...", + "#.#|.*.|#.#", + "*.*|.*.|*.*", + "#.#|.*.|#.#", + "...|.*.|...", + }}, + + {4, []string{ // armored gauntlet: a walking wall run. Weave the mid row or jump it; the shield covers one mistimed lane change + "...|.S.|...", + "...|.*.|...", + "...|##.|...", + "...|.##|...", + "...|#.#|...", + "...|.*.|...", + }}, +} + +// pattern is the parsed, playable form +type pattern struct { + minLevel int + chords []types.Chord +} + +var patterns []pattern + +func init() { + patterns = make([]pattern, len(patternDefs)) + for i, d := range patternDefs { + p := pattern{minLevel: d.minLevel, chords: make([]types.Chord, len(d.chords))} + for j, s := range d.chords { + p.chords[j] = parseChord(s) + } + patterns[i] = p + } +} + +// parseChord decodes a "TTT|MMM|BBB" literal; panics on malformed input or on +// a kind the engine cannot resolve (author error, caught at process start). +// Byte indexing is sound: types asserts every glyph is ASCII +func parseChord(s string) types.Chord { + rows := strings.Split(s, "|") + if len(rows) != parameter.GamePlayIndexYMax { + panic(fmt.Sprintf("pattern chord %q: want %d rows", s, parameter.GamePlayIndexYMax)) + } + var c types.Chord + for y, row := range rows { + if len(row) != parameter.GamePlayIndexXMax { + panic(fmt.Sprintf("pattern row %q: want %d cells", row, parameter.GamePlayIndexXMax)) + } + for x, r := range row { + v, ok := types.ValueByGlyph(r) + if !ok { + panic(fmt.Sprintf("pattern cell %q: unknown glyph", string(r))) + } + if v != types.ValueNone && resolvers[v] == nil { + panic(fmt.Sprintf("pattern cell %q: %s has no engine resolver", string(r), v)) + } + c.Notes[y][x] = types.Note{Value: v} + } + } + return c +} + +// newSong builds a level: intro rest, pattern chains separated by rest gaps +// that shrink with level, outro rest. The pool widens as level unlocks higher +// minLevel entries; chains lengthen with level +func newSong(level int) *types.Song { + length := min( + parameter.SongBaseLength+(level-1)*parameter.SongLengthPerLevel, + parameter.GamePlayIndexZMax, + ) + + pool := make([]pattern, 0, len(patterns)) + for _, p := range patterns { + if p.minLevel <= level { + pool = append(pool, p) + } + } + + chords := make([]types.Chord, 0, length) + chords = append(chords, make([]types.Chord, parameter.GenIntroRest)...) + + budget := length - parameter.GenOutroRest + rest := max(parameter.GenRestBase-(level-1), parameter.GenRestMin) + +build: + for { + chain := parameter.GenChainBase + + rand.IntN(min(level, parameter.GenChainMax-parameter.GenChainBase)+1) + for range chain { + p := pool[rand.IntN(len(pool))] + if len(chords)+len(p.chords) > budget { + break build + } + // Value-copy append: runtime consumption mutates the Song, + // never the templates + chords = append(chords, p.chords...) + if rand.Float64() < parameter.GenBreatherProbability && len(chords) < budget { + chords = append(chords, types.Chord{}) + } + } + if len(chords)+rest > budget { + break + } + chords = append(chords, make([]types.Chord, rest)...) + } + + // Pad to exact length; tail padding doubles as outro rest + for len(chords) < length { + chords = append(chords, types.Chord{}) + } + + return &types.Song{Chords: chords} +} diff --git a/game/status.go b/game/status.go new file mode 100644 index 0000000..668c9c9 --- /dev/null +++ b/game/status.go @@ -0,0 +1,65 @@ +package game + +import ( + "symph/parameter" + "time" +) + +// Status is the persistent player effect state granted by items. It survives a +// level change and is cleared on restart. Timed effects hold an absolute +// deadline, charge effects a count. Adding an effect adds a field here plus a +// row in resolvers — the pause/interlude shift and the restart clear are +// already funneled through this type +type Status struct { + BoostUntil time.Time // zero or past = inactive + Shield int // charges; each absorbs one negative contact. No expiry +} + +// Shielded reports whether an absorb charge is held +func (s Status) Shielded() bool { return s.Shield > 0 } + +// Boosted reports whether a Boost is active at instant t. Drives the tempo +// (deltaAt, evaluated at beat boundaries) and hazard immunity (hitHazard, +// evaluated at the contact instant) +func (s Status) Boosted(t time.Time) bool { + return !s.BoostUntil.IsZero() && t.Before(s.BoostUntil) +} + +// BoostRemaining reports the Boost span left at t; 0 when inactive +func (s Status) BoostRemaining(t time.Time) time.Duration { + if !s.Boosted(t) { + return 0 + } + return s.BoostUntil.Sub(t) +} + +// shift moves every absolute deadline forward by d, holding the remaining span +// across a pause or a level-clear interlude +// `at` is the instant the dead span opened. A deadline already lapsed +// at `at` is cleared, not shifted — dead time never revives an effect +func (s *Status) shift(at time.Time, d time.Duration) { + if s.BoostUntil.IsZero() { + return + } + if !s.Boosted(at) { + s.BoostUntil = time.Time{} + return + } + s.BoostUntil = s.BoostUntil.Add(d) +} + +// grantShield arms the absorb charge. Charges do not stack: a pickup while +// shielded re-arms +func (s *Status) grantShield() { s.Shield = parameter.ShieldCharges } + +// grantBoost re-arms the tempo deadline +func (s *Status) grantBoost(now time.Time) { s.BoostUntil = now.Add(parameter.BoostDuration) } + +// absorb spends one charge; reports whether one was held +func (s *Status) absorb() bool { + if s.Shield <= 0 { + return false + } + s.Shield-- + return true +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..a573e2e --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module symph + +go 1.26.4 + +require ( + github.com/gen2brain/raylib-go/raylib v0.60.0 + github.com/lixenwraith/color v0.0.0-20260714170240-79433f872c42 + github.com/lixenwraith/terminal v0.0.0-20260714170427-21041633b2c3 +) + +require ( + github.com/ebitengine/purego v0.10.1 // indirect + github.com/jupiterrider/ffi v0.7.0 // indirect + golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/term v0.45.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fde2950 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gen2brain/raylib-go/raylib v0.60.0 h1:KsP7W3EMkmb9zztivdgbh1bXR78pEuqP6jeUR8GCbEA= +github.com/gen2brain/raylib-go/raylib v0.60.0/go.mod h1:puAMU7Zcx6VJ6pcZSSs3gGFPyFvJuTwQlfm4KzeoXy8= +github.com/jupiterrider/ffi v0.7.0 h1:RKsl6Ascal+3kyAqR5Qcbp83LceQMLc1VZbPfHWoNzs= +github.com/jupiterrider/ffi v0.7.0/go.mod h1:9dauhpOfNqrqk28fxuu0kkdeFtT9Qr4vbfigiuIXN7c= +github.com/lixenwraith/color v0.0.0-20260714170240-79433f872c42 h1:wBqu4zX4jtEOhQVTI1x492N/DKrNpawY4RSxGyDlEXM= +github.com/lixenwraith/color v0.0.0-20260714170240-79433f872c42/go.mod h1:p02MsAGqlmZu3sc6BPYCKmaedF+lqBoijnuu5cOrYeo= +github.com/lixenwraith/terminal v0.0.0-20260714170427-21041633b2c3 h1:9zbFMAZMb8V4Q4N3pHO/I9oSGwB6RNKC/p+rDPjNPeE= +github.com/lixenwraith/terminal v0.0.0-20260714170427-21041633b2c3/go.mod h1:KSA1VFStFKduxZDbHlcA3ttdbMQf4dCd/9X36S/fS20= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= +golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= diff --git a/input/key.go b/input/key.go new file mode 100644 index 0000000..a2bf30a --- /dev/null +++ b/input/key.go @@ -0,0 +1,22 @@ +package input + +// Key is a platform-neutral physical key identifier. Terminal and raylib +// frontends translate native key events to Key before consulting KeyMap. +// Android/gomobile bypasses this package: Kotlin translates touch/gesture +// input to game.Action directly +type Key uint8 + +const ( + KeyNone Key = iota + KeyH // MoveLeft + KeyJ // MoveDown + KeyK // MoveUp + KeyL // MoveRight + KeySemicolon // stub + KeyA // stub + KeyS // stub + KeyD // stub + KeyF // stub + KeySpace + KeyEnter +) diff --git a/input/keymap.go b/input/keymap.go new file mode 100644 index 0000000..55fc744 --- /dev/null +++ b/input/keymap.go @@ -0,0 +1,26 @@ +package input + +import "symph/game" + +// KeyMap is the default vim-style binding +var KeyMap = map[Key]game.Action{ + KeyH: game.ActionMoveLeft, + KeyJ: game.ActionMoveDown, + KeyK: game.ActionMoveUp, + KeyL: game.ActionMoveRight, + KeySemicolon: game.ActionStubSemicolon, + KeyA: game.ActionStubA, + KeyS: game.ActionStubS, + KeyD: game.ActionStubD, + KeyF: game.ActionStubF, + KeySpace: game.ActionSpace, + KeyEnter: game.ActionConfirm, +} + +// Translate resolves a Key to its bound Action, ActionNone if unbound +func Translate(k Key) game.Action { + if a, ok := KeyMap[k]; ok { + return a + } + return game.ActionNone +} diff --git a/mobile/.gitkeep b/mobile/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/parameter/char.go b/parameter/char.go new file mode 100644 index 0000000..6564bfc --- /dev/null +++ b/parameter/char.go @@ -0,0 +1,116 @@ +package parameter + +// Row-reset funnels, drawn in the inter-tile gap row of the player lane. +// Drop = jump expiry (falling to the base row), Rise = slide expiry. +// %s is the countdown; formatted width must not exceed RenderNoteWidth +const ( + DropFunnelFormat = `\\ %s //` + RiseFunnelFormat = `// %s \\` +) + +// Inbound — chords until the lane shuts. +// Clear — chords until the lane reopens. +// Open — the wall run reaches the window edge; the value is a lower bound. +// Trap — the lane reopens for <= TrapGapMax chords before shutting again; +// flagged on both the inbound and the clear timer +const ( + TimerInboundPrefix = '▼' + TimerClearPrefix = '▲' + TimerOpenSuffix = '+' + TimerTrapSuffix = '!' +) + +// StripEmptyChar is lookahead strip rail cell — a chord with no content at this position +const StripEmptyChar = '·' + +// ItemFadeChars is the glyph tail of a consumed item's burn-out. Stage 0 +// keeps the item glyph; later stages collapse it into the rail cell +var ItemFadeChars = [...]rune{'+', StripEmptyChar} + +// UnknownChar is placeholder for a Value with no authored visual. +// Keeps NoteVisual total so an unrendered kind is visible rather than eating an item slot +const UnknownChar = '?' + +// MutedText marks the host audio gate in the header. ASCII, so it +// renders in both atlases +const MutedText = "MUTE" + +// QuadrantChars provides 2x2 sub-cell resolution for TrueColor mode +// Bitmap encoding: bit0=UL, bit1=UR, bit2=LL, bit3=LR +// Layout: [UL][UR] +// +// [LL][LR] +var QuadrantChars = [16]rune{ + ' ', // 0000 - empty + '▘', // 0001 - upper-left + '▝', // 0010 - upper-right + '▀', // 0011 - upper half + '▖', // 0100 - lower-left + '▌', // 0101 - left half + '▞', // 0110 - anti-diagonal + '▛', // 0111 - UL + UR + LL + '▗', // 1000 - lower-right + '▚', // 1001 - diagonal + '▐', // 1010 - right half + '▜', // 1011 - UL + UR + LR + '▄', // 1100 - lower half + '▙', // 1101 - UL + LL + LR + '▟', // 1110 - UR + LL + LR + '█', // 1111 - full block +} + +// Half256Chars provides vertical half-cell resolution for 256-color mode. +// Unicode block characters, CP437-equivalent visuals, naked-TTY compatible +// Bitmap encoding: bit0=top, bit1=bottom +var Half256Chars = [4]rune{ + ' ', // 00 - empty + '\u2580', // 01 - top half only (▀) + '\u2584', // 10 - bottom half only (▄) + '\u2588', // 11 - both halves (█) +} + +// Horizontal256Chars provides horizontal half-cell characters. +// Reserved for future horizontal sub-pixel support +var Horizontal256Chars = [2]rune{ + '\u258C', // ▌ - left half + '\u258E', // ▐ - right half +} + +// Density256Chars provides intensity variants for trail and glow effects, +// ordered lowest to highest density. Also indexes wall proximity bands +var Density256Chars = [4]rune{ + '\u2591', // ░ - light shade (25%) + '\u2592', // ▒ - medium shade (50%) + '\u2593', // ▓ - dark shade (75%) + '\u2588', // █ - full block (100%) +} + +// Single-line box drawing characters +const ( + BorderSingleHorizontal = '─' // U+2500 + BorderSingleVertical = '│' // U+2502 + BorderSingleTopLeft = '┌' // U+250C + BorderSingleTopRight = '┐' // U+2510 + BorderSingleBottomLeft = '└' // U+2514 + BorderSingleBottomRight = '┘' // U+2518 + BorderSingleVerticalRight = '├' // U+251C + BorderSingleVerticalLeft = '┤' // U+2524 + BorderSingleHorizontalDown = '┬' // U+252C + BorderSingleHorizontalUp = '┴' // U+2534 + BorderSingleCross = '┼' // U+253C +) + +// Double-line box drawing characters +const ( + BorderDoubleHorizontal = '═' // U+2550 + BorderDoubleVertical = '║' // U+2551 + BorderDoubleTopLeft = '╔' // U+2554 + BorderDoubleTopRight = '╗' // U+2557 + BorderDoubleBottomLeft = '╚' // U+255A + BorderDoubleBottomRight = '╝' // U+255D + BorderDoubleVerticalRight = '╠' // U+2560 + BorderDoubleVerticalLeft = '╣' // U+2563 + BorderDoubleHorizontalDown = '╦' // U+2566 + BorderDoubleHorizontalUp = '╩' // U+2569 + BorderDoubleCross = '╬' // U+256C +) diff --git a/parameter/game.go b/parameter/game.go new file mode 100644 index 0000000..0428a1a --- /dev/null +++ b/parameter/game.go @@ -0,0 +1,159 @@ +package parameter + +import ( + "time" +) + +// Core game grid and timing +const ( + // GameRenderUpdate is the frontend frame interval (~60fps). Beat + // quantization is owned by the engine; this only paces UI wakeups + GameRenderUpdate = 16 * time.Millisecond + + // GameDeltaZBase is the level-1 inter-chord interval. GameDeltaZStep + // shortens it per level down to the GameDeltaZMin floor + GameDeltaZBase = 500 * time.Millisecond + GameDeltaZStep = 20 * time.Millisecond + GameDeltaZMin = 300 * time.Millisecond + + // PlayerRowResetChords chords at whatever tempo is running, so a pattern's + // landing chord is invariant across levels and across a Boost. + // 3 * GameDeltaZBase == the previous 1500ms; level 1 is unchanged. + // Lane (X) shifts are persistent and never auto-reset + PlayerRowResetChords = 3 + + // ItemFadeDuration is the burn-out span of a consumed positive item. + // Kept under GameDeltaZMin/BoostSpeedFactor so the effect always completes inside one + // chord and reads as consumption, not as the strip scrolling + ItemFadeDuration = 140 * time.Millisecond + + // ItemFlashFraction is the leading white-flash portion of the burn-out + ItemFlashFraction = 0.25 + + // TransitionDuration is the level-clear interlude length + TransitionDuration = 2000 * time.Millisecond + + // Song length ceiling + GamePlayIndexZMax = 256 + + // Chord dimensions + GamePlayIndexYMax = 3 + GamePlayIndexXMax = 3 + + // Player spawn position (grid center) + GamePlayIndexYStart = GamePlayIndexYMax / 2 + GamePlayIndexXStart = GamePlayIndexXMax / 2 +) + +// Song generation +const ( + // SongBaseLength + (level-1)*SongLengthPerLevel chords per level, + // capped at GamePlayIndexZMax + SongBaseLength = 72 + SongLengthPerLevel = 12 + + // Guaranteed empty chords at song edges (grace-in, clean finish) + GenIntroRest = 6 + GenOutroRest = 4 + + // Rest gap between pattern chains: GenRestBase shrinks by one per level + // down to GenRestMin + GenRestBase = 7 + GenRestMin = 2 + + // Patterns chained back-to-back per burst + GenChainBase = 1 + GenChainMax = 5 + + // Chance of a single empty chord between chained patterns + GenBreatherProbability = 0.5 +) + +// Lookahead observation window. Window width covers every wall band; band +// geometry and trap threshold are parameterized here +const ( + // PositionLookaheadWindow is how many upcoming chords (starting at the + // current one, distance 0) are scanned per grid position. + // Hard ceiling: game.TileLookaheadMaxWindow (wall bitmask width) + PositionLookaheadWindow = 10 + + // Wall proximity bands. Band 0 is the arrived wall (distance 0); bands + // 1..WallBandCount-1 each span WallBandSize chords. Band index selects + // border fill density and color + WallBandSize = 3 + WallBandCount = 4 + + // TrapGapMax is the largest reopen gap still flagged as a trap: a lane + // that clears for <= this many chords before another wall shuts it + TrapGapMax = 2 + + // ConsumeRingSize bounds the consumed-note ring the frontends read + // for burn-out. A magnet sweep can consume up to + // GamePlayIndexYMax*GamePlayIndexXMax*PositionLookaheadWindow notes; on + // overflow the oldest burn-out is dropped (visual-only degradation) + ConsumeRingSize = 64 + + // MorphWindow is the chord-distance over which a non-wall Value's color + // interpolates from distant to arrived + MorphWindow = 4 +) + +// Player effects granted by items. Both survive a level change and are cleared on restart +const ( + // BoostDuration is the wall-clock lifetime of a Boost. A second pickup + // refreshes the deadline, it does not extend it + BoostDuration = 10 * time.Second + + // BoostSpeedFactor divides the inter-chord interval while a Boost runs. + // Integer divisor: the boosted interval stays exact. A slow effect would + // multiply in the same place + BoostSpeedFactor = 2 + + // BoostWarnRemaining is the Boost tail over which the HUD countdown blinks. + // Expiry inside a wall band kills on the next beat; the player + // needs the lead time to leave the lane + BoostWarnRemaining = 2 * time.Second + + // ShieldCharges is the charge count a Shield pickup arms. Each charge + // absorbs one negative contact; charges never expire and do not stack + ShieldCharges = 1 +) + +// The burn-out must finish inside the tightest beat — the level floor divided by the Boost factor. +// Also proves the boosted interval is > 0, which bounds the Update beat loop +const _ = uint(GameDeltaZMin/BoostSpeedFactor - ItemFadeDuration) + +// Compile-time: the window must reach the far edge of the last band +const _ = uint(PositionLookaheadWindow - (WallBandSize*(WallBandCount-1) + 1)) + +// Tile geometry derives from the strip. PositionLookaheadWindow is +// the only knob; width follows so the strip is 1:1 with the interior +const ( + RenderMarginX = 1 + RenderMarginY = 1 + + // RenderHeaderRows is the chrome above the grid (LEVEL / ENERGY). + // No footer, no title + RenderHeaderRows = 1 + + // One interior column per lookahead chord, plus the two border columns + RenderNoteWidth = PositionLookaheadWindow + 2 + RenderNoteHeight = 5 + + RenderGapX = 2 + RenderGapY = 1 + + // Fixed interior row offsets, relative to the tile top border + RenderRowTimer = 1 + RenderRowDist = 2 + RenderRowStrip = 3 + + // Distance row: "gDD gDD" — glyph + chord-distance, positive group then negative group + RenderDistDigits = 2 + RenderDistCols = 2*(1+RenderDistDigits) + 1 + + // RenderBlinkPeriod is the half-cycle of the nearest-item blink. The + // distance-row group of the tile(s) holding the grid-wide nearest + // positive/negative occurrence alternates between two colors at this rate + RenderBlinkPeriod = 120 * time.Millisecond +) diff --git a/platform/android/.gitkeep b/platform/android/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/render/definition.go b/render/definition.go new file mode 100644 index 0000000..92cbb13 --- /dev/null +++ b/render/definition.go @@ -0,0 +1,42 @@ +package render + +import ( + "fmt" + "symph/parameter" +) + +type noteTile = [parameter.RenderNoteHeight][parameter.RenderNoteWidth]rune + +var noteChar noteTile + +func init() { + funnel := max( + len(fmt.Sprintf(parameter.DropFunnelFormat, "0.0")), + len(fmt.Sprintf(parameter.RiseFunnelFormat, "0.0")), + ) + inner := parameter.RenderNoteWidth - 2 + + // inner == PositionLookaheadWindow by construction; assert the + // distance row and funnel still fit + if parameter.RenderNoteHeight < 2+parameter.RenderRowStrip || + inner < parameter.RenderDistCols || + parameter.RenderNoteWidth < funnel || + parameter.RenderGapY < 1 { + panic("Render tile too small: need height>=5, width-2>=RenderDistCols, width>=funnel, gapY>=1") + } + + noteChar[0][0] = parameter.BorderSingleTopLeft + noteChar[parameter.RenderNoteHeight-1][0] = parameter.BorderSingleBottomLeft + noteChar[0][parameter.RenderNoteWidth-1] = parameter.BorderSingleTopRight + noteChar[parameter.RenderNoteHeight-1][parameter.RenderNoteWidth-1] = parameter.BorderSingleBottomRight + + for i := 1; i < parameter.RenderNoteWidth-1; i++ { + noteChar[0][i] = parameter.BorderSingleHorizontal + noteChar[parameter.RenderNoteHeight-1][i] = parameter.BorderSingleHorizontal + } + + for i := 1; i < parameter.RenderNoteHeight-1; i++ { + noteChar[i][0] = parameter.BorderSingleVertical + noteChar[i][parameter.RenderNoteWidth-1] = parameter.BorderSingleVertical + } +} diff --git a/render/fade.go b/render/fade.go new file mode 100644 index 0000000..feb732d --- /dev/null +++ b/render/fade.go @@ -0,0 +1,47 @@ +package render + +import ( + "time" + + "symph/game" + "symph/parameter" + "symph/types" +) + +// FadeGrid projects the engine's consumed-note ring onto strip coordinates for +// one frame: burn-out progress per (y, x, chord-distance). Entries outside the +// window or past ItemFadeDuration are dropped. A consumed cell at distance 0 +// scrolls off on the next beat; the fade is shorter than the tightest beat, so +// it always resolves before the strip shifts +type FadeGrid struct { + value [parameter.GamePlayIndexYMax][parameter.GamePlayIndexXMax][game.TileLookaheadMaxWindow]types.Value + t [parameter.GamePlayIndexYMax][parameter.GamePlayIndexXMax][game.TileLookaheadMaxWindow]float64 +} + +func (f *FadeGrid) Refresh(s *game.GameState, now time.Time) { + *f = FadeGrid{} + for _, c := range s.Consumed() { + if c.Value == types.ValueNone { + continue + } + age := now.Sub(c.At) + if age < 0 || age >= parameter.ItemFadeDuration { + continue + } + d := c.Z - s.CurrentPlayIndexZ + if d < 0 || d >= game.TileLookaheadMaxWindow { + continue + } + f.value[c.Y][c.X][d] = c.Value + f.t[c.Y][c.X][d] = float64(age) / float64(parameter.ItemFadeDuration) + } +} + +// At reports the item burning out at a strip cell and its progress in [0,1) +func (f *FadeGrid) At(y, x, d int) (types.Value, float64, bool) { + if d < 0 || d >= game.TileLookaheadMaxWindow { + return types.ValueNone, 0, false + } + v := f.value[y][x][d] + return v, f.t[y][x][d], v != types.ValueNone +} diff --git a/render/raylib/renderer.go b/render/raylib/renderer.go new file mode 100644 index 0000000..bb53905 --- /dev/null +++ b/render/raylib/renderer.go @@ -0,0 +1,320 @@ +//go:build linux + +// Package raylib is the graphical frontend. Presentation parity with the +// terminal renderer: wall-band ring, wall timer, distance row with grid-wide +// blink, lookahead strip, player fill, reset funnel, consumption burn-out. +// Policy is sourced from package render; only the medium differs. +// +// The raylib default font atlas covers codepoints 32..126 +// Walls draw as rectangles, only the distance row needs the substitute rune. +package raylib + +import ( + "fmt" + "math" + "time" + + rl "github.com/gen2brain/raylib-go/raylib" + + "symph/game" + "symph/parameter" + "symph/render" + "symph/types" + + "github.com/lixenwraith/color" +) + +const ( + gridFrac = 0.92 // grid extent as a fraction of the usable area + tileAspect = 0.62 // tile height / width; mirrors the 12x5 terminal tile + gapFrac = 0.03 + + // Independent gap fractions. The row gap hosts the reset funnel, + // so it scales with tile height, not with usable width + gapFracX = 0.04 // column gap / usable width + gapFracY = 0.22 // row gap / tile height + + // Funnel countdown geometry + funnelFrac = 0.85 // font size / row gap + funnelFontMin int32 = 22 + + ringPx = 2.0 + ringWallPx = 4.0 + wallBodyA = 70 // alpha of the arrived-wall body fill + + rowTimerY = 0.16 // tile-relative content rows + rowDistY = 0.42 + rowStripY = 0.74 + + fontHUD int32 = 20 + fontBanner int32 = 40 +) + +// Renderer draws GameState with raylib primitives +type Renderer struct { + state *game.GameState + grid render.GridScan + fade render.FadeGrid + + Muted bool +} + +func NewRenderer(s *game.GameState) *Renderer { + return &Renderer{state: s} +} + +func toRl(c color.RGB) rl.Color { return rl.NewColor(c.R, c.G, c.B, 255) } + +// Draw renders one frame. Call between rl.BeginDrawing()/EndDrawing() +func (r *Renderer) Draw(screenW, screenH int32, now time.Time) { + rl.ClearBackground(rl.Black) + + hud := float32(screenH) * 0.07 + lvl := fmt.Sprintf("LEVEL %d", r.state.Level) + if r.Muted { + lvl += " " + parameter.MutedText + } + rl.DrawText(lvl, 12, 10, fontHUD, toRl(color.CoolSilver)) + en := fmt.Sprintf("ENERGY %d", r.state.Energy) + rl.DrawText(en, screenW-rl.MeasureText(en, fontHUD)-12, 10, fontHUD, toRl(color.PaleGold)) + + if s := render.StatusText(r.state, now); s != "" { + drawCentered(s, float32(screenW)/2, 10, fontHUD, toRl(render.StatusColor(r.state, now))) + } + + switch r.state.Phase { + case game.PhaseLevelClear: + r.drawLevelClear(screenW, screenH, now) + case game.PhaseGameOver: + r.drawField(screenW, screenH, hud, now) + r.drawGameOver(screenW, screenH) + default: + r.drawField(screenW, screenH, hud, now) + } +} + +// tileGeom is the resolved per-frame grid layout +type tileGeom struct{ ox, oy, tw, th, gx, gy float32 } + +func layout(w, h int32, hud float32) tileGeom { + nx := float32(parameter.GamePlayIndexXMax) + ny := float32(parameter.GamePlayIndexYMax) + + availW := float32(w) * gridFrac + availH := (float32(h) - hud) * gridFrac + + g := tileGeom{} + g.gx = availW * gapFracX + + g.tw = (availW - (nx-1)*g.gx) / nx + g.th = g.tw * tileAspect + g.gy = g.th * gapFracY // Row gap derives from tile height + + // Height-bound screens: refit from the vertical budget. + // gy is proportional to th, so solve for th directly + if ny*g.th+(ny-1)*g.gy > availH { + g.th = availH / (ny + (ny-1)*gapFracY) + g.tw = g.th / tileAspect + g.gy = g.th * gapFracY + } + + gw := nx*g.tw + (nx-1)*g.gx + gh := ny*g.th + (ny-1)*g.gy + g.ox = (float32(w) - gw) / 2 + g.oy = hud + (float32(h)-hud-gh)/2 + return g +} + +func (r *Renderer) drawField(w, h int32, hud float32, now time.Time) { + r.grid.Refresh(r.state) + r.fade.Refresh(r.state, now) + + g := layout(w, h, hud) + live := r.state.Phase == game.PhasePlaying && !r.state.Paused + + for y := range parameter.GamePlayIndexYMax { + for x := range parameter.GamePlayIndexXMax { + rect := rl.NewRectangle( + g.ox+float32(x)*(g.tw+g.gx), + g.oy+float32(y)*(g.th+g.gy), + g.tw, g.th) + r.drawTile(rect, y, x, live, now) + } + } + if live { + r.drawResetFunnel(g, now) + } +} + +// drawTile renders one grid position. The player fill runs first: raylib paints +// opaque, so the background cannot be applied after the glyphs +func (r *Renderer) drawTile(rect rl.Rectangle, y, x int, live bool, now time.Time) { + tl := r.grid.Tiles[y][x] + seg := tl.NearestWall() + + if y == r.state.PlayerState.PlayIndexY && x == r.state.PlayerState.PlayIndexX { + rl.DrawRectangleRec(rect, toRl(render.PlayerBgFor(r.state.Status, now))) + } + + ring, thick := toRl(render.RingIdle), float32(ringPx) + switch { + case seg.Distance == 0: + // Lane lethal at the current chord: solid body, matches the terminal + // border fill + ring, thick = toRl(render.WallBandColor(0)), float32(ringWallPx) + body := ring + body.A = wallBodyA + rl.DrawRectangleRec(rect, body) + case seg.Distance > 0: + ring = toRl(render.WallBandColor(seg.Distance)) + } + rl.DrawRectangleLinesEx(rect, thick, ring) + + fs := fontSize(rect) + r.drawWallTimer(rect, fs, seg, live, now) + r.drawDistRow(rect, fs, tl, live, now) + r.drawStrip(rect, y, x, tl) +} + +func fontSize(rect rl.Rectangle) int32 { return max(int32(rect.Height*0.19), 10) } + +func drawCentered(s string, cx, y float32, fs int32, c rl.Color) { + rl.DrawText(s, int32(cx)-rl.MeasureText(s, fs)/2, int32(y), fs, c) +} + +func (r *Renderer) drawWallTimer(rect rl.Rectangle, fs int32, seg game.WallSegment, live bool, now time.Time) { + if !live { + return + } + s, c := render.WallTimerText(render.TimerASCII, r.state, seg, now) + if s == "" { + return + } + drawCentered(s, rect.X+rect.Width/2, rect.Y+rect.Height*rowTimerY, fs, toRl(c)) +} + +// drawDistRow draws two fixed polarity groups: nearest help, nearest threat. +// A group whose distance equals the grid-wide nearest for its polarity blinks; +// ties are not broken +func (r *Renderer) drawDistRow(rect rl.Rectangle, fs int32, tl game.TileLookahead, live bool, now time.Time) { + if tl.Positive.Distance < 0 && tl.Negative.Distance < 0 { + return + } + cx, y := rect.X+rect.Width/2, rect.Y+rect.Height*rowDistY + off := rect.Width * 0.19 + + r.drawDistGroup(cx-off, y, fs, tl.Positive, + live && tl.Positive.Distance == r.grid.NearPos, render.BlinkPositive, now) + r.drawDistGroup(cx+off, y, fs, tl.Negative, + live && tl.Negative.Distance == r.grid.NearNeg, render.BlinkNegative, now) +} + +func (r *Renderer) drawDistGroup(cx, y float32, fs int32, it game.ItemStat, blink bool, pair [2]color.RGB, now time.Time) { + if it.Distance < 0 { + return + } + ch, c := render.TileGlyphASCII(it.Value, it.Distance) + if blink { + c = render.BlinkColor(pair, now) + } + drawCentered(string(ch)+render.DistText(it.Distance), cx, y, fs, toRl(c)) +} + +// drawStrip renders the lane timeline: one cell per chord distance, leftmost = +// the current chord. Items draw at arrived color (distance is positional), +// walls keep the band gradient so ring, timer and strip agree. An emptied cell +// with a pending burn-out flares and collapses in place +func (r *Renderer) drawStrip(rect rl.Rectangle, y, x int, tl game.TileLookahead) { + pad := rect.Width * 0.06 + cw := (rect.Width - 2*pad) / float32(parameter.PositionLookaheadWindow) + cy := rect.Y + rect.Height*rowStripY + rad := min(cw*0.42, rect.Height*0.10) + + for d := range parameter.PositionLookaheadWindow { + cx := rect.X + pad + (float32(d)+0.5)*cw + if d >= tl.Window { + continue + } + + switch v := tl.Cells[d]; { + case v == types.ValueWall: + _, c := render.TileGlyph(v, d) + rl.DrawRectangleRec(rl.NewRectangle(cx-cw*0.4, cy-rad, cw*0.8, rad*2), toRl(c)) + + case v != types.ValueNone: + _, c := render.TileGlyph(v, 0) + rl.DrawCircle(int32(cx), int32(cy), rad, toRl(c)) + + default: + fv, t, ok := r.fade.At(y, x, d) + if !ok { + rl.DrawCircle(int32(cx), int32(cy), rad*0.28, toRl(color.DimGray)) + continue + } + c := toRl(render.FadeColor(fv, t)) + c.A = uint8(255 * (1 - t)) // burn out to transparent + rl.DrawCircle(int32(cx), int32(cy), rad*float32(1.6-1.2*t), c) + } + } + + // The chord being played + x0 := rect.X + pad + rl.DrawLineEx( + rl.NewVector2(x0, cy+rad*1.6), rl.NewVector2(x0+cw, cy+rad*1.6), + 1.5, toRl(color.CoolSilver)) +} + +// drawResetFunnel renders the pending row-reset countdown in the gap adjacent +// to the base-row tile of the player lane. Orientation follows the return +// direction (fall after a jump, rise after a slide) +func (r *Renderer) drawResetFunnel(g tileGeom, now time.Time) { + p := r.state.PlayerState + if p.ResetTime.IsZero() || p.PlayIndexY == p.BaseIndexY { + return + } + + baseY := g.oy + float32(p.BaseIndexY)*(g.th+g.gy) + laneX := g.ox + float32(p.PlayIndexX)*(g.tw+g.gx) + + arrow, gapY := "v", baseY-g.gy + if p.PlayIndexY > p.BaseIndexY { + arrow, gapY = "^", baseY+g.th + } + + fs := max(int32(g.gy*funnelFrac), funnelFontMin) + s := fmt.Sprintf("%s %s %s", arrow, render.TimerText(p.ResetTime.Sub(now)), arrow) + drawCentered(s, laneX+g.tw/2, gapY+(g.gy-float32(fs))/2, fs, rl.White) +} + +// --- Phase overlays --- + +// drawLevelClear renders expanding hue-cycled rings with the clear banner +func (r *Renderer) drawLevelClear(w, h int32, now time.Time) { + el := now.Sub(r.state.PhaseStart).Seconds() + cx, cy := w/2, h/2 + maxR := math.Hypot(float64(cx), float64(cy)) + + for i := range 24 { + rr := float32(math.Mod(el*220+float64(i)*44, maxR)) + col := rl.ColorFromHSV(float32(math.Mod(el*120+float64(i)*15, 360)), 0.8, 1) + col.A = 200 + rl.DrawCircleLines(cx, cy, rr, col) + } + + msg := fmt.Sprintf("LEVEL %d CLEAR", r.state.Level) + rl.DrawText(msg, cx-rl.MeasureText(msg, fontBanner)/2, cy-fontBanner, fontBanner, rl.White) + sub := fmt.Sprintf("ENERGY %d", r.state.Energy) + rl.DrawText(sub, cx-rl.MeasureText(sub, fontHUD)/2, cy+12, fontHUD, rl.LightGray) +} + +// drawGameOver overlays the death banner on the frozen field (the fatal wall +// stays visible) +func (r *Renderer) drawGameOver(w, h int32) { + rl.DrawRectangle(0, 0, w, h, rl.NewColor(0, 0, 0, 160)) + msg := "GAME OVER" + rl.DrawText(msg, w/2-rl.MeasureText(msg, fontBanner)/2, h/2-fontBanner, fontBanner, toRl(color.BrightRed)) + sub := fmt.Sprintf("ENERGY %d - LEVEL %d", r.state.Energy, r.state.Level) + rl.DrawText(sub, w/2-rl.MeasureText(sub, fontHUD)/2, h/2+8, fontHUD, toRl(color.Silver)) + hint := "ENTER TO RESTART" + rl.DrawText(hint, w/2-rl.MeasureText(hint, fontHUD)/2, h/2+34, fontHUD, toRl(color.DimSilver)) +} diff --git a/render/render.go b/render/render.go new file mode 100644 index 0000000..71f3597 --- /dev/null +++ b/render/render.go @@ -0,0 +1,458 @@ +// Package render draws GameState to a terminal cell buffer. +// +// Tile encoding (12x5): +// - border — thin ring, colored by nearest-wall proximity band. +// Solid fill only when a wall occupies the current chord +// - interior r1 — wall timer, fixed position. ▼ = chords until the lane +// shuts, ▲ = chords until it reopens. '+' lower bound, +// '!' trap gap +// - interior r2 — distance row: nearest positive and nearest negative +// occurrence, each as glyph + chord-distance. The tile(s) holding the +// grid-wide nearest occurrence of a polarity blink that group +// - interior r3 — lookahead strip: one cell per chord, leftmost = now +// - background — player position +// +// The header row carries LEVEL, the active-effect segment (shield charge, boost countdown), and ENERGY. +// +// The inter-tile gap row of the player lane hosts the row-reset funnel. +package render + +import ( + "fmt" + "math" + "time" + "unicode/utf8" + + "symph/game" + "symph/parameter" + "symph/types" + + "github.com/lixenwraith/color" + "github.com/lixenwraith/terminal" +) + +type Renderer struct { + term terminal.Terminal + state *game.GameState + cells []terminal.Cell + height, width int + + Muted bool + + // grid-scope scan and burn-out projection are shared helpers + grid GridScan + fade FadeGrid +} + +func NewRenderer(t terminal.Terminal, w, h int, s *game.GameState) *Renderer { + r := &Renderer{term: t, state: s, height: h, width: w, cells: make([]terminal.Cell, w*h)} + r.clearFrame() + return r +} + +func (r *Renderer) Resize(w, h int) { + r.height, r.width = h, w + r.cells = make([]terminal.Cell, w*h) + r.clearFrame() +} + +// clearFrame blanks the logical buffer. Phase overlays paint outside tile +// rectangles, so per-frame clearing prevents stale cells across phase +// switches; the terminal Flush diffs, keeping the cost in-process +func (r *Renderer) clearFrame() { + for i := range r.cells { + r.cells[i] = terminal.Cell{Rune: ' ', Bg: color.Black} + } +} + +// MinSize is the frame size required to draw the full field. Below it +// drawChar clips silently, so the frontend pauses and shows the notice +func MinSize() (w, h int) { + gh, gw := gridSize() + return gw + 2*parameter.RenderMarginX, + gh + parameter.RenderHeaderRows + 2*parameter.RenderMarginY +} + +func (r *Renderer) TooSmall() bool { + mw, mh := MinSize() + return r.width < mw || r.height < mh +} + +func (r *Renderer) drawTooSmall() { + mw, mh := MinSize() + lines := [...]string{ + "TERMINAL TOO SMALL", + fmt.Sprintf("need %dx%d", mw, mh), + fmt.Sprintf("have %dx%d", r.width, r.height), + "PAUSED - RESIZE TO RESUME", + } + top := max(0, (r.height-len(lines))/2) + for i, s := range lines { + fg := color.Silver + if i == 0 { + fg = color.BrightRed + } + drawText(r.cells, r.height, r.width, top+i, + max(0, (r.width-runeLen(s))/2), s, fg, terminal.AttrBold) + } +} + +// Render draws one frame: chrome, then the phase-appropriate field +func (r *Renderer) Render(now time.Time) { + // Ensure no terminal size desync + w, h := r.term.Size() + if w <= 0 || h <= 0 || w != r.width || h != r.height { + return + } + + r.clearFrame() + + if r.TooSmall() { + r.drawTooSmall() + r.term.Flush(r.cells, w, h) + return + } + + topY, topX := r.gridOrigin() + _, gridW := gridSize() + + hudL := fmt.Sprintf("LEVEL %d", r.state.Level) + if r.Muted { + hudL += " " + parameter.MutedText + } + hudR := fmt.Sprintf("ENERGY %d", r.state.Energy) + drawText(r.cells, h, w, topY-1, topX, hudL, color.CoolSilver, terminal.AttrBold) + drawText(r.cells, h, w, topY-1, topX+gridW-runeLen(hudR), hudR, color.PaleGold, terminal.AttrBold) + + // Active effects, centered between the HUD anchors + if s := StatusText(r.state, now); s != "" { + drawText(r.cells, h, w, topY-1, topX+(gridW-runeLen(s))/2, s, + StatusColor(r.state, now), terminal.AttrBold) + } + + switch r.state.Phase { + case game.PhaseLevelClear: + r.drawLevelClear(topY, topX, now) + case game.PhaseGameOver: + r.drawChordGrid(topY, topX, now) + r.drawGameOver(topY) + default: + r.drawChordGrid(topY, topX, now) + r.drawResetFunnel(topY, topX, now) + } + + r.term.Flush(r.cells, w, h) +} + +// --- Geometry --- + +// gridSize returns the grid extent including inter-tile gaps +func gridSize() (h, w int) { + h = parameter.GamePlayIndexYMax*parameter.RenderNoteHeight + + (parameter.GamePlayIndexYMax-1)*parameter.RenderGapY + w = parameter.GamePlayIndexXMax*parameter.RenderNoteWidth + + (parameter.GamePlayIndexXMax-1)*parameter.RenderGapX + return h, w +} + +// gridOrigin computes the centered top-left cell for the chord grid, clamped below the header row +func (r *Renderer) gridOrigin() (topY, topX int) { + gridH, gridW := gridSize() + + topX = max(parameter.RenderMarginX, (r.width-gridW)/2) + + topY = (r.height - gridH) / 2 + topY = max(topY, parameter.RenderMarginY+parameter.RenderHeaderRows) + topY = min(topY, r.height-parameter.RenderMarginY-gridH) + + return topY, topX +} + +// tileOrigin resolves the top-left cell of grid tile (y,x) +func tileOrigin(topY, topX, y, x int) (int, int) { + return topY + y*(parameter.RenderNoteHeight+parameter.RenderGapY), + topX + x*(parameter.RenderNoteWidth+parameter.RenderGapX) +} + +// --- Playfield --- + +func (r *Renderer) drawChordGrid(topY, topX int, now time.Time) { + r.grid.Refresh(r.state) + r.fade.Refresh(r.state, now) + for iy := range parameter.GamePlayIndexYMax { + for ix := range parameter.GamePlayIndexXMax { + ty, tx := tileOrigin(topY, topX, iy, ix) + r.drawTile(ty, tx, iy, ix, now) + } + } +} + +// drawTile renders one grid position: wall-encoded border, distance row, wall +// timer, lookahead strip, and the player background fill. Background fill runs +// last so it recolors without clobbering foreground glyphs +func (r *Renderer) drawTile(topY, topX, y, x int, now time.Time) { + tl := r.grid.Tiles[y][x] + seg := tl.NearestWall() + + switch { + case seg.Distance == 0: + r.drawWallRing(topY, topX, WallSolid, WallBandColor(0)) + case seg.Distance > 0: + r.drawBoxRing(topY, topX, WallBandColor(seg.Distance)) + default: + r.drawBoxRing(topY, topX, RingIdle) + } + + // Timers and the blink are meaningless outside a running PhasePlaying: + // LastPlayedTime is frozen and the field does not advance + live := r.state.Phase == game.PhasePlaying && !r.state.Paused + + r.drawWallTimer(topY+parameter.RenderRowTimer, topX, seg, live, now) + r.drawDistRow(topY+parameter.RenderRowDist, topX, tl, live, now) + r.drawStrip(topY+parameter.RenderRowStrip, topX, tl, y, x) // Fade lookup needs (y,x) + + if y == r.state.PlayerState.PlayIndexY && x == r.state.PlayerState.PlayIndexX { + r.fillTileBg(topY, topX, PlayerBgFor(r.state.Status, now)) + } +} + +// drawWallTimer renders the fixed-position wall countdown. +func (r *Renderer) drawWallTimer(row, topX int, seg game.WallSegment, live bool, now time.Time) { + if !live { + return + } + s, fg := WallTimerText(TimerUnicode, r.state, seg, now) + if s == "" { + return + } + r.drawTileText(row, topX, s, fg, terminal.AttrBold) +} + +// drawStrip renders the lane timeline — one cell per chord distance, leftmost +// = the current chord. Distance is carried positionally, so items render at +// their arrived color and stay legible at range; walls keep the band gradient, +// matching the ring. A cell emptied by consumption burns out in place +func (r *Renderer) drawStrip(row, topX int, tl game.TileLookahead, y, x int) { + col := topX + 1 // inner == PositionLookaheadWindow + + for d := range parameter.PositionLookaheadWindow { + ch, fg := parameter.StripEmptyChar, color.DimGray + + if d < tl.Window { + switch v := tl.Cells[d]; { + case v == types.ValueWall: + ch, fg = TileGlyph(v, d) + case v != types.ValueNone: + ch, fg = TileGlyph(v, 0) + default: + // burn-out of a note the engine just consumed + if fv, t, ok := r.fade.At(y, x, d); ok { + ch, fg = FadeVisual(fv, t) + } + } + } + + attr := terminal.AttrNone + if d == 0 { + attr = terminal.AttrBold // the chord being played + } + drawChar(r.cells, r.height, r.width, row, col+d, ch, fg, attr) + } +} + +// drawWallRing fills the tile border with the wall proximity glyph. Adjacent +// walled tiles merge into a contiguous mass +func (r *Renderer) drawWallRing(topY, topX int, glyph rune, fg color.RGB) { + for ty := range parameter.RenderNoteHeight { + for tx := range parameter.RenderNoteWidth { + if ty > 0 && ty < parameter.RenderNoteHeight-1 && + tx > 0 && tx < parameter.RenderNoteWidth-1 { + continue + } + drawChar(r.cells, r.height, r.width, topY+ty, topX+tx, glyph, fg, terminal.AttrNone) + } + } +} + +// drawBoxRing draws the wall-free border from the tile template +// ring color is a parameter — carries wall proximity when the wall +// has not arrived +func (r *Renderer) drawBoxRing(topY, topX int, fg color.RGB) { + for ty := range parameter.RenderNoteHeight { + for tx := range parameter.RenderNoteWidth { + if ch := noteChar[ty][tx]; ch != 0 { + drawChar(r.cells, r.height, r.width, topY+ty, topX+tx, ch, fg, terminal.AttrNone) + } + } + } +} + +// drawDistRow draws two fixed polarity groups: nearest help, nearest threat, +// each as glyph + chord-distance. 0 = at the current chord; an absent kind +// draws nothing. A group whose distance equals the grid-wide nearest for its +// polarity blinks — several tiles can tie and all of them blink +func (r *Renderer) drawDistRow(row, topX int, tl game.TileLookahead, live bool, now time.Time) { + if tl.Positive.Distance < 0 && tl.Negative.Distance < 0 { + return + } + + inner := parameter.RenderNoteWidth - 2 + col := topX + 1 + (inner-parameter.RenderDistCols)/2 + + // grid-wide minima read from GridScan + r.drawDistGroup(row, col, tl.Positive, + live && tl.Positive.Distance == r.grid.NearPos, BlinkPositive, now) + r.drawDistGroup(row, col+parameter.RenderDistDigits+2, tl.Negative, + live && tl.Negative.Distance == r.grid.NearNeg, BlinkNegative, now) +} + +func (r *Renderer) drawDistGroup(row, col int, it game.ItemStat, blink bool, pair [2]color.RGB, now time.Time) { + if it.Distance < 0 { + return + } + ch, fg := TileGlyph(it.Value, it.Distance) + if ch == 0 { + return + } + if blink { + fg = BlinkColor(pair, now) + } + drawChar(r.cells, r.height, r.width, row, col, ch, fg, terminal.AttrBold) + drawText(r.cells, r.height, r.width, row, col+1, DistText(it.Distance), fg, terminal.AttrBold) +} + +// drawResetFunnel renders the pending row-reset countdown in the gap row +// adjacent to the base-row tile of the player lane. Funnel orientation follows +// the return direction (fall after a jump, rise after a slide) +func (r *Renderer) drawResetFunnel(topY, topX int, now time.Time) { + p := r.state.PlayerState + if p.ResetTime.IsZero() || p.PlayIndexY == p.BaseIndexY { + return + } + + baseY, laneX := tileOrigin(topY, topX, p.BaseIndexY, p.PlayIndexX) + + format, row := parameter.DropFunnelFormat, baseY-parameter.RenderGapY + if p.PlayIndexY > p.BaseIndexY { + format, row = parameter.RiseFunnelFormat, baseY+parameter.RenderNoteHeight + } + + s := fmt.Sprintf(format, TimerText(p.ResetTime.Sub(now))) + drawText(r.cells, r.height, r.width, row, + laneX+(parameter.RenderNoteWidth-len(s))/2, s, color.White, terminal.AttrBold) +} + +// --- Phase overlays --- + +// transitionPalette cycles the level-clear wave hues +var transitionPalette = [...]color.RGB{ + color.Vermilion, color.TigerOrange, color.Gold, + color.BrightGreen, color.BrightCyan, color.Cornflower, color.HotMagenta, +} + +// drawLevelClear paints a radial color wave expanding from the grid center +// with the clear banner on top — retro inter-level interlude +func (r *Renderer) drawLevelClear(topY, topX int, now time.Time) { + gridH, gridW := gridSize() + + const padY, padX = 1, 3 + cy := float64(topY) + float64(gridH-1)/2 + cx := float64(topX) + float64(gridW-1)/2 + el := now.Sub(r.state.PhaseStart).Seconds() + n := float64(len(transitionPalette)) + + for y := topY - padY; y < topY+gridH+padY; y++ { + for x := topX - padX; x < topX+gridW+padX; x++ { + dy := (float64(y) - cy) * 2.0 // terminal cell aspect ~1:2 + dx := float64(x) - cx + ph := math.Hypot(dx, dy)*0.35 - el*8 + pos := math.Mod(math.Mod(ph, n)+n, n) // positive wrap into palette cycle + i := int(pos) + frac := pos - float64(i) + col := transitionPalette[i].Lerp(transitionPalette[(i+1)%len(transitionPalette)], frac) + glyph := parameter.Density256Chars[int(frac*float64(len(parameter.Density256Chars)))] + drawChar(r.cells, r.height, r.width, y, x, glyph, col, terminal.AttrNone) + } + } + + msg := fmt.Sprintf(" LEVEL %d CLEAR ", r.state.Level) + sub := fmt.Sprintf(" ENERGY %d ", r.state.Energy) + midY := topY + gridH/2 + fg := color.White + if int(el*4)%2 == 0 { + fg = color.PaleLemon // blink + } + drawText(r.cells, r.height, r.width, midY-1, max(0, (r.width-len(msg))/2), msg, fg, terminal.AttrBold) + drawText(r.cells, r.height, r.width, midY+1, max(0, (r.width-len(sub))/2), sub, color.Silver, terminal.AttrBold) +} + +// drawGameOver overlays the death banner on the frozen grid (fatal wall stays +// visible) +func (r *Renderer) drawGameOver(topY int) { + gridH, _ := gridSize() + midY := topY + gridH/2 + + msg := " G A M E O V E R " + sub := fmt.Sprintf(" ENERGY %d - LEVEL %d ", r.state.Energy, r.state.Level) + hint := " ENTER TO RESTART " + + drawText(r.cells, r.height, r.width, midY-1, max(0, (r.width-len(msg))/2), msg, color.BrightRed, terminal.AttrBold) + drawText(r.cells, r.height, r.width, midY, max(0, (r.width-len(sub))/2), sub, color.Silver, terminal.AttrNone) + drawText(r.cells, r.height, r.width, midY+1, max(0, (r.width-len(hint))/2), hint, color.DimSilver, terminal.AttrNone) +} + +// --- Cell primitives --- + +func runeLen(s string) int { return utf8.RuneCountInString(s) } + +// drawTileText centers s across the tile interior columns. Width is +// rune-indexed; byte length misplaces the multi-byte timer affixes +func (r *Renderer) drawTileText(row, topX int, s string, fg color.RGB, attr terminal.Attr) { + inner := parameter.RenderNoteWidth - 2 + n := runeLen(s) + if n > inner { + return + } + drawText(r.cells, r.height, r.width, row, topX+1+(inner-n)/2, s, fg, attr) +} + +// fillTileBg recolors the tile rectangle background in place. Runs after all +// tile glyphs are written, so foreground content is preserved +func (r *Renderer) fillTileBg(topY, topX int, bg color.RGB) { + for ty := range parameter.RenderNoteHeight { + y := topY + ty + if y < 0 || y >= r.height { + continue + } + for tx := range parameter.RenderNoteWidth { + x := topX + tx + if x < 0 || x >= r.width { + continue + } + r.cells[y*r.width+x].Bg = bg + } + } +} + +// drawChar writes a single cell, bounds-checked +func drawChar(cells []terminal.Cell, h, w, y, x int, char rune, fg color.RGB, attr terminal.Attr) { + if y < 0 || y >= h || x < 0 || x >= w { + return + } + cells[y*w+x] = terminal.Cell{Rune: char, Fg: fg, Bg: color.Black, Attrs: attr} +} + +// drawText writes a horizontal string, rune-indexed (byte indexing misplaces +// columns for non-ASCII text). Out-of-bounds columns are skipped +func drawText(cells []terminal.Cell, h, w, y, x int, text string, fg color.RGB, attr terminal.Attr) { + if y < 0 || y >= h { + return + } + sx := x + for _, r := range text { + if sx >= 0 && sx < w { + cells[y*w+sx] = terminal.Cell{Rune: r, Fg: fg, Bg: color.Black, Attrs: attr} + } + sx++ + } +} diff --git a/render/scan.go b/render/scan.go new file mode 100644 index 0000000..c65d910 --- /dev/null +++ b/render/scan.go @@ -0,0 +1,32 @@ +package render + +import ( + "symph/game" + "symph/parameter" +) + +// GridScan caches one frame of per-position lookahead and resolves the +// grid-wide nearest distances. Blink membership is a grid-scope property, so +// every position is scanned before any tile is drawn. Allocation-free: fixed +// arrays refilled in place, shared by both frontends +type GridScan struct { + Tiles [parameter.GamePlayIndexYMax][parameter.GamePlayIndexXMax]game.TileLookahead + NearPos int // grid-wide nearest positive distance; -1 when none + NearNeg int // grid-wide nearest negative distance; -1 when none +} + +func (g *GridScan) Refresh(s *game.GameState) { + g.NearPos, g.NearNeg = -1, -1 + for y := range parameter.GamePlayIndexYMax { + for x := range parameter.GamePlayIndexXMax { + tl := s.ScanTile(y, x, parameter.PositionLookaheadWindow) + g.Tiles[y][x] = tl + if d := tl.Positive.Distance; d >= 0 && (g.NearPos < 0 || d < g.NearPos) { + g.NearPos = d + } + if d := tl.Negative.Distance; d >= 0 && (g.NearNeg < 0 || d < g.NearNeg) { + g.NearNeg = d + } + } + } +} diff --git a/render/visual.go b/render/visual.go new file mode 100644 index 0000000..21587f2 --- /dev/null +++ b/render/visual.go @@ -0,0 +1,281 @@ +package render + +import ( + "fmt" + "symph/game" + "symph/parameter" + "symph/types" + "time" + + "github.com/lixenwraith/color" +) + +// Compile-time: one density glyph per wall band +const _ = uint(len(parameter.Density256Chars) - parameter.WallBandCount) + +// PlayerBg is the tile-wide background of the player position — deep indigo, +// contrasts against the black field while preserving legibility of every +// foreground band color (yellow/orange/red) and item color +var PlayerBg = color.RGB{R: 32, G: 40, B: 72} + +// A held shield charge and an active Boost are field state — immunity must read +// at the point of contact, not only in the HUD. Both fills stay dark: every +// wall band color and item color is high-luminance and must remain legible over +// the player tile +var ( + PlayerShieldBg = color.RGB{R: 40, G: 72, B: 88} + PlayerBoostBg = color.RGB{R: 64, G: 48, B: 16} +) + +// PlayerBgFor selects the player fill for the active status. Boost outranks +// Shield: it is the effect absorbing the contact +func PlayerBgFor(s game.Status, now time.Time) color.RGB { + switch { + case s.Boosted(now): + return PlayerBoostBg + case s.Shielded(): + return PlayerShieldBg + } + return PlayerBg +} + +// RingIdle is the border color of a tile with no wall in the window +var RingIdle = color.Teal + +// --- Wall proximity (terminal border encoding) --- + +// wallBandColor is indexed by proximity band (0 = arrived) +var wallBandColor = [parameter.WallBandCount]color.RGB{ + color.BrightRed, // 0: arrived + color.Vermilion, // 1: dark orange + color.TigerOrange, // 2: light orange + color.Yellow, // 3: far +} + +// WallSolid is the arrived-wall glyph (100% density) +var WallSolid = parameter.Density256Chars[len(parameter.Density256Chars)-1] + +// WallBand maps a chord distance to a proximity band (0 = arrived). +// Distances past the last band clamp to it +func WallBand(distance int) int { + if distance <= 0 { + return 0 + } + return min((distance+parameter.WallBandSize-1)/parameter.WallBandSize, parameter.WallBandCount-1) +} + +// WallBandColor maps a chord distance to its proximity band color. Band 0 +// (arrived) is bright red; receding bands cool through orange to yellow. +// Border fill is binary — a filled ring means the lane is lethal at the +// current chord — so approach urgency rides on ring color alone +func WallBandColor(distance int) color.RGB { + return wallBandColor[WallBand(distance)] +} + +// --- Item / note visuals --- + +// valueVisual is the appearance of a Value in the strip and distance row: the +// distant→arrived color ramp. Both endpoints are high-luminance so a distant +// item stays visible against the black field. Item ramps avoid the wall band +// family (red/orange/yellow); unauthored kinds fall back to the dim +// unknownVisual rather than eating an item slot +type valueVisual struct { + far, near color.RGB + authored bool +} + +var valueVisuals = [types.ValueCount]valueVisual{ + types.ValueEnergy: {color.BrightCyan, color.BrightGreen, true}, + types.ValueMagnet: {color.Cornflower, color.HotMagenta, true}, + types.ValueShield: {color.CoolSilver, color.White, true}, + types.ValueBoost: {color.PaleLemon, color.Gold, true}, +} + +var unknownVisual = valueVisual{color.DimSilver, color.Silver, true} + +// TileGlyph resolves the strip and distance-row glyph for a Value at a given chord distance. +// Walls follow the border band encoding so the ring, strip, and distance row agree. +// Items use the morph lerp over their authored ramp +func TileGlyph(v types.Value, distance int) (rune, color.RGB) { + if v == types.ValueWall { + return WallSolid, WallBandColor(distance) + } + if !v.Valid() || v == types.ValueNone { + return 0, color.RGB{} + } + t := proximity(distance) + if vis := valueVisuals[v]; vis.authored { + return v.Glyph(), vis.far.Lerp(vis.near, t) + } + return parameter.UnknownChar, unknownVisual.far.Lerp(unknownVisual.near, t) +} + +// TileGlyphASCII resolves the glyph for a frontend limited to the 32..126 atlas: +// the wall's density block is substituted with its authoring rune. Colors are +// identical, so the two frontends stay in presentation parity +func TileGlyphASCII(v types.Value, distance int) (rune, color.RGB) { + ch, c := TileGlyph(v, distance) + if v == types.ValueWall { + ch = v.Glyph() + } + return ch, c +} + +// proximity maps a chord distance to [0,1]: 1 = arrived (distance<=0), +// 0 = at or beyond MorphWindow chords away +func proximity(distance int) float64 { + if distance <= 0 { + return 1 + } + if distance >= parameter.MorphWindow { + return 0 + } + return 1 - float64(distance)/float64(parameter.MorphWindow) +} + +// --- HUD effect segment --- + +// StatusText composes the active-effect HUD segment: the Shield charge glyph +// and the Boost countdown. Glyphs are the ASCII authoring runes, so the string +// is renderable in both atlases. Empty outside a running PhasePlaying — the +// deadlines are frozen there and a ticking countdown would be a lie +func StatusText(s *game.GameState, now time.Time) string { + if s.Phase != game.PhasePlaying || s.Paused { + return "" + } + out := make([]rune, 0, 8) + if s.Status.Shielded() { + out = append(out, types.ValueShield.Glyph()) + } + if s.Status.Boosted(now) { + if len(out) > 0 { + out = append(out, ' ') + } + out = append(out, types.ValueBoost.Glyph(), ' ') + out = append(out, []rune(TimerText(s.Status.BoostRemaining(now)))...) + } + return string(out) +} + +// StatusColor tints the effect segment with the arrived color of the expiring +// effect — the Boost when one runs, else the Shield +func StatusColor(s *game.GameState, now time.Time) color.RGB { + if rem := s.Status.BoostRemaining(now); rem > 0 { + if rem <= parameter.BoostWarnRemaining { + return BlinkColor(BlinkNegative, now) + } + _, c := TileGlyph(types.ValueBoost, 0) + return c + } + _, c := TileGlyph(types.ValueShield, 0) + return c +} + +// --- Consumption burn-out --- + +// FadeColor is the burn-out color of a consumed item at progress t in +// [0,1): a white flash, then a sink into the field. Distinguishes consumption +// from the cell scrolling out from under the strip +func FadeColor(v types.Value, t float64) color.RGB { + _, base := TileGlyph(v, 0) + if t < parameter.ItemFlashFraction { + return color.White.Lerp(base, t/parameter.ItemFlashFraction) + } + u := (t - parameter.ItemFlashFraction) / (1 - parameter.ItemFlashFraction) + return base.Lerp(color.Black, u) +} + +// FadeVisual adds the glyph collapse for the terminal strip: the item +// glyph holds through the flash, then decays into the rail cell +func FadeVisual(v types.Value, t float64) (rune, color.RGB) { + glyph, _ := TileGlyph(v, 0) + if t >= parameter.ItemFlashFraction { + u := (t - parameter.ItemFlashFraction) / (1 - parameter.ItemFlashFraction) + if i := int(u * float64(len(parameter.ItemFadeChars)+1)); i > 0 { + glyph = parameter.ItemFadeChars[min(i-1, len(parameter.ItemFadeChars)-1)] + } + } + return glyph, FadeColor(v, t) +} + +// --- Nearest-occurrence blink --- + +// Blink pairs for the distance-row group of the tile(s) holding the grid-wide +// nearest occurrence of each polarity. Both endpoints are high-luminance: the +// cue must read as motion, not as a dip to the background +var ( + BlinkPositive = [2]color.RGB{color.White, color.BrightGreen} + BlinkNegative = [2]color.RGB{color.Yellow, color.BrightRed} +) + +// BlinkColor selects the phase color of a pair. Phase is derived from the +// wall clock, not from PlayDeltaZ: the cue must stay legible when the beat +// tightens at higher levels +func BlinkColor(pair [2]color.RGB, now time.Time) color.RGB { + return pair[(now.UnixNano()/int64(parameter.RenderBlinkPeriod))&1] +} + +// --- Text composition (shared by both frontends) --- + +// TimerGlyphs is the wall-countdown affix set. The raylib default font +// atlas covers 32..126 only, so it substitutes TimerASCII +type TimerGlyphs struct{ Inbound, Clear, Open, Trap rune } + +var ( + TimerUnicode = TimerGlyphs{ + parameter.TimerInboundPrefix, parameter.TimerClearPrefix, + parameter.TimerOpenSuffix, parameter.TimerTrapSuffix, + } + TimerASCII = TimerGlyphs{'v', '^', '+', '!'} +) + +// WallTimerText composes the wall countdown for a segment. Inbound +// counts down to closure, clear counts down to the reopen chord. The trap +// marker is not arrival-only: an inbound wall whose reopen gap is a trap is +// flagged on approach. Returns "" when no wall is in the window +func WallTimerText(g TimerGlyphs, s *game.GameState, seg game.WallSegment, now time.Time) (string, color.RGB) { + if seg.Distance < 0 { + return "", color.RGB{} + } + + out := make([]rune, 0, 8) + fg := WallBandColor(seg.Distance) + + if seg.Distance == 0 { + out = append(out, g.Clear) + out = append(out, []rune(TimerText(s.TimeToChordDistance(seg.Run, now)))...) + if seg.Open { + out = append(out, g.Open) + } + fg = color.White + } else { + out = append(out, g.Inbound) + out = append(out, []rune(TimerText(s.TimeToChordDistance(seg.Distance, now)))...) + } + + if seg.Next && seg.Gap <= parameter.TrapGapMax { + out = append(out, g.Trap) + } + return string(out), fg +} + +// TimerText formats a countdown as "S.s", clamped to +// [0.0, 9.9]. Window * PlayDeltaZ stays under 10s at all levels; the clamp is a guard +func TimerText(d time.Duration) string { + s := d.Seconds() + switch { + case s < 0: + s = 0 + case s > 9.9: + s = 9.9 + } + return fmt.Sprintf("%.1f", s) +} + +// DistText formats a chord-distance into RenderDistDigits columns +func DistText(d int) string { + if d < 0 { + return "" + } + return fmt.Sprintf("%d", min(d, 99)) +} diff --git a/scripts/build-android.sh b/scripts/build-android.sh new file mode 100755 index 0000000..e69de29 diff --git a/scripts/build-raylib.sh b/scripts/build-raylib.sh new file mode 100755 index 0000000..e69de29 diff --git a/types/game.go b/types/game.go new file mode 100644 index 0000000..5b83263 --- /dev/null +++ b/types/game.go @@ -0,0 +1,40 @@ +package types + +import ( + "time" + + "symph/parameter" +) + +// Note represents a single musical note +type Note struct { + Value Value + + // TODO: more properties to be added +} + +// IsEmpty reports whether the Note has no content +func (n Note) IsEmpty() bool { return n.Value == ValueNone } + +// IsWall reports whether the Note is a Wall obstacle +func (n Note) IsWall() bool { return n.Value == ValueWall } + +// Chord represents a 3x3 grid of notes +type Chord struct { + Notes [parameter.GamePlayIndexYMax][parameter.GamePlayIndexXMax]Note // 3x3 grid of notes +} + +type Song struct { + Chords []Chord +} + +// PlayerState holds the current playing position and temporal displacement state. +// Lane (X) position is persistent; row (Y) displacement expires back to BaseIndexY +type PlayerState struct { + ResetTime time.Time // When the current row displacement expires (Zero = no reset) + + PlayIndexY int // Current Y coordinate + PlayIndexX int // Current X coordinate + + BaseIndexY int // Resting row (returns here after jump/slide expiry) +} diff --git a/types/value.go b/types/value.go new file mode 100644 index 0000000..96c182a --- /dev/null +++ b/types/value.go @@ -0,0 +1,122 @@ +package types + +import "fmt" + +// Value identifies the content of a Note. The enum is taxonomy only: per-kind +// semantics live in valueSpecs, engine behavior in game.resolvers, appearance +// in render.valueVisuals. No switch keys on a Value +type Value int + +const ( + ValueNone Value = iota + // Blocks + ValueWall + ValueSpike + // Force direction + ValueLeft + ValueDown + ValueUp + ValueRight + // Power up + ValueEnergy + ValueBoost + ValueShield + ValueMagnet + // Enemies + ValueDrain + ValueFortifiedLeft + ValueFortifiedDown + ValueFortifiedUp + ValueFortifiedRight + ValueFortifiedAll + // Count + ValueCount +) + +// Polarity classifies a Value's effect on the player. Drives the +// tile distance row (nearest help / nearest threat) +type Polarity uint8 + +const ( + PolarityNeutral Polarity = iota + PolarityPositive + PolarityNegative +) + +// ValueSpec is the static description of a Value — the single source of truth +// for kind identity. Adding a kind is a row in valueSpecs, never a switch edit. +// Glyph is both the pattern authoring rune and the fallback display glyph for +// frontends limited to the 32..126 atlas; ASCII range and uniqueness are asserted +type ValueSpec struct { + Name string + Glyph rune + Polarity Polarity +} + +// valueSpecs is indexed by Value. Every kind below ValueCount needs a row +var valueSpecs = [ValueCount]ValueSpec{ + ValueNone: {"none", '.', PolarityNeutral}, + + ValueWall: {"wall", '#', PolarityNegative}, + ValueSpike: {"spike", 'x', PolarityNegative}, + + ValueLeft: {"left", '<', PolarityNeutral}, + ValueDown: {"down", 'v', PolarityNeutral}, + ValueUp: {"up", '^', PolarityNeutral}, + ValueRight: {"right", '>', PolarityNeutral}, + + ValueEnergy: {"energy", '*', PolarityPositive}, + ValueBoost: {"boost", 'B', PolarityPositive}, + ValueShield: {"shield", 'S', PolarityPositive}, + ValueMagnet: {"magnet", 'M', PolarityPositive}, + + ValueDrain: {"drain", '~', PolarityNegative}, + ValueFortifiedLeft: {"fortified-left", 'L', PolarityNegative}, + ValueFortifiedDown: {"fortified-down", 'J', PolarityNegative}, + ValueFortifiedUp: {"fortified-up", 'K', PolarityNegative}, + ValueFortifiedRight: {"fortified-right", 'R', PolarityNegative}, + ValueFortifiedAll: {"fortified-all", 'O', PolarityNegative}, +} + +// glyphValue is the reverse index consumed by the pattern parser +var glyphValue map[rune]Value + +// Author errors (missing row, non-ASCII or duplicate glyph) are caught at +// process start, matching parseChord +func init() { + glyphValue = make(map[rune]Value, ValueCount) + for v := ValueNone; v < ValueCount; v++ { + s := valueSpecs[v] + switch { + case s.Name == "": + panic(fmt.Sprintf("types: Value %d has no spec row", int(v))) + case s.Glyph < ' ' || s.Glyph > '~': + panic(fmt.Sprintf("types: Value %d glyph outside ASCII 32..126", int(v))) + } + if _, dup := glyphValue[s.Glyph]; dup { + panic(fmt.Sprintf("types: glyph %q assigned twice", string(s.Glyph))) + } + glyphValue[s.Glyph] = v + } +} + +// Valid reports whether v is a defined kind +func (v Value) Valid() bool { return v >= ValueNone && v < ValueCount } + +// Spec returns the description of v; the None row when v is out of range +func (v Value) Spec() ValueSpec { + if !v.Valid() { + return valueSpecs[ValueNone] + } + return valueSpecs[v] +} + +func (v Value) Polarity() Polarity { return v.Spec().Polarity } +func (v Value) Glyph() rune { return v.Spec().Glyph } +func (v Value) String() string { return v.Spec().Name } + +// ValueByGlyph resolves an authoring rune to its Value +func ValueByGlyph(r rune) (Value, bool) { + v, ok := glyphValue[r] + return v, ok +}