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