v0.1.0 initial commit

This commit is contained in:
2026-07-15 01:28:56 -04:00
commit 874abee663
35 changed files with 3486 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
bin/
log/
dev/
debug/
catalog.txt
build.sh
+28
View File
@@ -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.
+23
View File
@@ -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
+88
View File
@@ -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`.
+35
View File
@@ -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() {}
+7
View File
@@ -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{} }
+30
View File
@@ -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
}
+27
View File
@@ -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 }
+53
View File
@@ -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)
}
+81
View File
@@ -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()
}
+91
View File
@@ -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
}
+140
View File
@@ -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
}
+239
View File
@@ -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.
+54
View File
@@ -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)
}
}
+563
View File
@@ -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)
}
}
}
}
}
+258
View File
@@ -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}
}
+65
View File
@@ -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
}
+17
View File
@@ -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
)
+16
View File
@@ -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=
+22
View File
@@ -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
)
+26
View File
@@ -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
}
View File
+116
View File
@@ -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
)
+159
View File
@@ -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
)
View File
+42
View File
@@ -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
}
}
+47
View File
@@ -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
}
+320
View File
@@ -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))
}
+458
View File
@@ -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++
}
}
+32
View File
@@ -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
}
}
}
}
+281
View File
@@ -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))
}
View File
View File
+40
View File
@@ -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)
}
+122
View File
@@ -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
}