v0.1.0 initial commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
bin/
|
||||
log/
|
||||
dev/
|
||||
debug/
|
||||
catalog.txt
|
||||
build.sh
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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() {}
|
||||
@@ -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{} }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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++
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
Executable
Executable
@@ -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
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user