v0.1.0 initial commit
This commit is contained in:
+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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user