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