Files
symph/game/game.go
T
2026-07-15 01:28:56 -04:00

564 lines
18 KiB
Go

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)
}
}
}
}
}