564 lines
18 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|