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