Last active
May 26, 2026 03:01
-
-
Save LucasAlfare/4d5f8d9b86c77317da55dfaf275dbd88 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // try to accumulate events to dispatch once; maybe flag then adn, then, | |
| // include a new pipeline step to dispatch the data? | |
| package com.lucasalfare.flgf.core.game | |
| /** | |
| * | |
| * Represents a note in the song chart. | |
| * | |
| * @property hitTime Timestamp (in milliseconds) when the note should be hit. | |
| * | |
| * @property lane Which lane/fret this note belongs to. | |
| * | |
| * @property duration Duration (in milliseconds) for sustain notes (0 = tap note). | |
| * | |
| * @property isSpecial Whether this note contributes to special energy sequences. | |
| */ | |
| data class Note(val hitTime: Long, val lane: Int, val duration: Long = 0L, val isSpecial: Boolean = false) | |
| /** | |
| * | |
| * Runtime representation of a note currently active in the game. | |
| * | |
| * This wraps a static [Note] and adds mutable state used during gameplay. | |
| * | |
| * @property note The original immutable note data. | |
| * | |
| * @property hit Whether the note has been successfully hit. | |
| * | |
| * @property missed Whether the note has been missed. | |
| * | |
| * @property holding Whether the player is currently holding a sustain note. | |
| * | |
| * @property sustainProgress How much of the sustain duration has been completed. | |
| * | |
| * @property sustainBroken Whether the player released a sustain before it finished. | |
| */ | |
| data class NoteState( | |
| val note: Note, | |
| var hit: Boolean = false, | |
| var missed: Boolean = false, | |
| var holding: Boolean = false, | |
| var sustainProgress: Double = 0.0, | |
| var sustainBroken: Boolean = false, | |
| var specialDisabled: Boolean = false, | |
| var specialPhraseEnd: Boolean = false, | |
| var index: Int = 0 | |
| ) | |
| /** | |
| * | |
| * Tracks scoring-related state. | |
| * | |
| * @property score Total accumulated score. | |
| * | |
| * @property combo Current combo streak. | |
| * | |
| * @property multiplier Score multiplier based on combo thresholds. | |
| */ | |
| data class ScoreState(var score: Int = 0, var combo: Int = 0, var multiplier: Int = 1) | |
| /** | |
| * Single combo threshold that upgrades the score multiplier. | |
| */ | |
| data class ComboMultiplierTier(val comboThreshold: Int, val multiplier: Int) | |
| /** | |
| * Tunable rules for the special meter and special-active score behavior. | |
| */ | |
| data class SpecialScoringRules( | |
| val energyPerCompletedSpecialPhrase: Int = 25, | |
| val activationEnergyThreshold: Int = 50, | |
| val maximumEnergy: Int = 100, | |
| val drainPerSecond: Double = 25.0, | |
| val activeScoreMultiplier: Int = 2 | |
| ) | |
| /** | |
| * All score-related tuning knobs in one place. | |
| * | |
| * This keeps hit value, combo tiers, sustain value, and special meter behavior | |
| * centralized so gameplay balancing can be adjusted without touching the engine flow. | |
| */ | |
| data class ScoringRules( | |
| val pointsPerHit: Int = 50, | |
| val sustainPointsPerSecond: Double = 50.0, | |
| val comboMultiplierTiers: List<ComboMultiplierTier> = listOf( | |
| ComboMultiplierTier(comboThreshold = 10, multiplier = 2), | |
| ComboMultiplierTier(comboThreshold = 20, multiplier = 3), | |
| ComboMultiplierTier(comboThreshold = 30, multiplier = 4) | |
| ), | |
| val special: SpecialScoringRules = SpecialScoringRules() | |
| ) { | |
| fun multiplierFor(combo: Int): Int { | |
| return comboMultiplierTiers | |
| .sortedBy { it.comboThreshold } | |
| .lastOrNull { combo >= it.comboThreshold } | |
| ?.multiplier ?: 1 | |
| } | |
| fun hitPoints(combo: Int, specialActive: Boolean): Int { | |
| return pointsPerHit * multiplierFor(combo) * specialScoreMultiplier(specialActive) | |
| } | |
| fun sustainPoints(deltaMs: Double, comboMultiplier: Int, specialActive: Boolean): Int { | |
| return (deltaMs / 1000.0 * sustainPointsPerSecond * comboMultiplier * specialScoreMultiplier(specialActive)).toInt() | |
| } | |
| fun canActivateSpecial(energy: Int): Boolean { | |
| return energy >= special.activationEnergyThreshold | |
| } | |
| fun drainSpecialEnergy(currentEnergy: Int, deltaMs: Long): Int { | |
| val drained = (special.drainPerSecond * deltaMs / 1000.0).toInt() | |
| return (currentEnergy - drained).coerceAtLeast(0) | |
| } | |
| fun gainSpecialEnergy(currentEnergy: Int): Int { | |
| return (currentEnergy + special.energyPerCompletedSpecialPhrase).coerceAtMost(special.maximumEnergy) | |
| } | |
| private fun specialScoreMultiplier(active: Boolean): Int { | |
| return if (active) special.activeScoreMultiplier else 1 | |
| } | |
| } | |
| /** | |
| * Thin gameplay helper that applies [ScoringRules] to mutable score state. | |
| */ | |
| class ScoringSystem( | |
| private val rules: ScoringRules = ScoringRules() | |
| ) { | |
| fun multiplierFor(combo: Int): Int = rules.multiplierFor(combo) | |
| fun registerHit(score: ScoreState, specialActive: Boolean): Int { | |
| score.combo++ | |
| score.multiplier = rules.multiplierFor(score.combo) | |
| val points = rules.hitPoints(score.combo, specialActive) | |
| score.score += points | |
| return points | |
| } | |
| fun registerSustain(score: ScoreState, deltaMs: Double, specialActive: Boolean): Int { | |
| val points = rules.sustainPoints(deltaMs, score.multiplier, specialActive) | |
| score.score += points | |
| return points | |
| } | |
| fun resetCombo(score: ScoreState) { | |
| score.combo = 0 | |
| score.multiplier = 1 | |
| } | |
| fun canActivateSpecial(energy: Int): Boolean = rules.canActivateSpecial(energy) | |
| fun gainSpecialEnergy(currentEnergy: Int): Int = rules.gainSpecialEnergy(currentEnergy) | |
| fun drainSpecialEnergy(currentEnergy: Int, deltaMs: Long): Int = rules.drainSpecialEnergy(currentEnergy, deltaMs) | |
| fun updateSpecialDrain(special: SpecialState, deltaMs: Long) { | |
| if (deltaMs <= 0L) return | |
| special.drainAccumulator += rules.special.drainPerSecond * deltaMs / 1000.0 | |
| val drainedEnergy = special.drainAccumulator.toInt() | |
| if (drainedEnergy > 0) { | |
| special.energy = (special.energy - drainedEnergy).coerceAtLeast(0) | |
| special.drainAccumulator -= drainedEnergy | |
| } | |
| if (special.energy <= 0) { | |
| special.energy = 0 | |
| special.active = false | |
| special.drainAccumulator = 0.0 | |
| } | |
| } | |
| } | |
| /** | |
| * | |
| * Tracks the special ability (e.g., "star power") system. | |
| * | |
| * @property energy Current stored energy (0–100). | |
| * | |
| * @property active Whether the special mode is currently active. | |
| * | |
| * @property inSequence Whether the player is currently inside a valid special-note sequence. | |
| * | |
| * @property sequenceBroken Whether the current sequence has been invalidated. | |
| */ | |
| data class SpecialState( | |
| var energy: Int = 0, | |
| var active: Boolean = false, | |
| var inSequence: Boolean = false, | |
| var sequenceBroken: Boolean = false, | |
| var drainAccumulator: Double = 0.0 | |
| ) | |
| /** | |
| * Represents the player's input state for a single frame/tick using bitmasks | |
| * for zero-allocation, cache-friendly fret state storage. | |
| * | |
| * Each fret lane corresponds to a bit position: lane 0 → bit 0, lane 1 → bit 1, | |
| * and so on. This allows up to 31 lanes on a standard 32-bit Int. | |
| * | |
| * Bitmask semantics: | |
| * - A bit set to 1 means that lane is active for the given state. | |
| * - A bit set to 0 means that lane is inactive. | |
| * | |
| * Example for a 5-lane guitar: lanes 0–4 map to bits 0–4 (values 1, 2, 4, 8, 16). | |
| * | |
| * This is a value class backed by a single Long that packs all four fields into | |
| * one 64-bit integer, making the entire input state a single primitive — no heap | |
| * allocation, no GC pressure, safe to create and discard every frame. | |
| * | |
| * Bit layout of the backing [packed] Long (low → high): | |
| * ``` | |
| * bits 0–14 : pressedFrets (15 bits, supports up to 15 lanes) | |
| * bits 15–29 : justPressedFrets | |
| * bits 30–44 : justReleasedFrets | |
| * bit 63 : activateSpecial | |
| * ``` | |
| * | |
| * Use the provided extension functions and constants to read individual fields | |
| * rather than manipulating [packed] directly. | |
| */ | |
| @JvmInline | |
| value class PlayerInput(val packed: Long) { | |
| /** | |
| * Bitmask of frets currently held down. | |
| * Bit i is set if lane i is pressed this frame. | |
| */ | |
| val pressedFrets: Int | |
| get() = (packed and PRESSED_MASK).toInt() | |
| /** | |
| * Bitmask of frets that transitioned from released to pressed this frame (edge-triggered). | |
| * Bit i is set if lane i was just pressed. | |
| */ | |
| val justPressedFrets: Int | |
| get() = ((packed ushr JUST_PRESSED_SHIFT) and FIELD_MASK).toInt() | |
| /** | |
| * Bitmask of frets that transitioned from pressed to released this frame (edge-triggered). | |
| * Bit i is set if lane i was just released. | |
| */ | |
| val justReleasedFrets: Int | |
| get() = ((packed ushr JUST_RELEASED_SHIFT) and FIELD_MASK).toInt() | |
| /** | |
| * True if the player is attempting to activate special mode this frame. | |
| */ | |
| val activateSpecial: Boolean | |
| get() = (packed and SPECIAL_MASK) != 0L | |
| /** | |
| * Returns true if the given [lane] is currently held down. | |
| */ | |
| fun isPressed(lane: Int): Boolean = (pressedFrets ushr lane) and 1 == 1 | |
| /** | |
| * Returns true if the given [lane] was just pressed this frame. | |
| */ | |
| fun isJustPressed(lane: Int): Boolean = (justPressedFrets ushr lane) and 1 == 1 | |
| /** | |
| * Returns true if the given [lane] was just released this frame. | |
| */ | |
| fun isJustReleased(lane: Int): Boolean = (justReleasedFrets ushr lane) and 1 == 1 | |
| companion object { | |
| // ── Bit layout constants ───────────────────────────────────────────── | |
| private const val FIELD_BITS = 15 | |
| private const val FIELD_MASK = (1L shl FIELD_BITS) - 1L // 0x7FFF | |
| const val PRESSED_MASK: Long = FIELD_MASK // bits 0–14 | |
| const val JUST_PRESSED_SHIFT: Int = FIELD_BITS // bit 15 | |
| const val JUST_RELEASED_SHIFT: Int = FIELD_BITS * 2 // bit 30 | |
| const val SPECIAL_MASK: Long = 1L shl 63 // bit 63 | |
| /** The empty/idle input: no frets pressed, special not activated. */ | |
| val NONE = PlayerInput(0L) | |
| /** | |
| * Constructs a [PlayerInput] from pre-computed bitmasks. | |
| * | |
| * Prefer this factory over the primary constructor when building inputs | |
| * from raw bitmask values (e.g. inside [InputHandler]). | |
| * | |
| * @param pressed Bitmask of currently held frets. | |
| * @param justPressed Bitmask of frets pressed this frame. | |
| * @param justReleased Bitmask of frets released this frame. | |
| * @param activateSpecial True if special activation was requested. | |
| */ | |
| fun of( | |
| pressed: Int, | |
| justPressed: Int, | |
| justReleased: Int, | |
| activateSpecial: Boolean | |
| ): PlayerInput { | |
| val special = if (activateSpecial) SPECIAL_MASK else 0L | |
| val packed = (pressed.toLong() and FIELD_MASK) or | |
| ((justPressed.toLong() and FIELD_MASK) shl JUST_PRESSED_SHIFT) or | |
| ((justReleased.toLong() and FIELD_MASK) shl JUST_RELEASED_SHIFT) or | |
| special | |
| return PlayerInput(packed) | |
| } | |
| } | |
| } | |
| /** | |
| * Iterates over every lane index whose bit is set in this bitmask, invoking | |
| * [block] for each. Equivalent to iterating a Set<Int> of active lanes but | |
| * with zero allocation. | |
| * | |
| * @param maxLanes Upper bound on the number of lanes to check (default 15). | |
| */ | |
| inline fun Int.forEachSetBit(maxLanes: Int = 15, block: (lane: Int) -> Unit) { | |
| var mask = this | |
| while (mask != 0) { | |
| val lane = Integer.numberOfTrailingZeros(mask) | |
| if (lane >= maxLanes) break | |
| block(lane) | |
| mask = mask and (mask - 1) // clear lowest set bit | |
| } | |
| } | |
| /** | |
| * Asymmetric timing tolerance for registering a valid note hit. | |
| * | |
| * Separating [early] and [late] tolerances allows tuning feel independently: | |
| * many rhythm games are more forgiving when the player anticipates a note | |
| * (hits early) than when they react late. Both values are in milliseconds, | |
| * matching the unit used by [Note.hitTime] and [GameEngine.tick]. | |
| * | |
| * A hit is valid when `delta` falls in `[-early, +late]`, | |
| * where `delta = currentTime - note.hitTime`. | |
| * Negative delta → player hit early. Positive delta → player hit late. | |
| * | |
| * @param early Milliseconds of tolerance *before* the note's hit time. | |
| * @param late Milliseconds of tolerance *after* the note's hit time. | |
| */ | |
| data class HitWindow(val early: Long, val late: Long) { | |
| /** | |
| * Returns true if [delta] (`currentTime - note.hitTime`) falls inside this window. | |
| */ | |
| fun contains(delta: Long): Boolean = delta >= -early && delta <= late | |
| companion object { | |
| /** Convenience factory for a symmetric ±[ms] window. */ | |
| fun symmetric(ms: Long) = HitWindow(early = ms, late = ms) | |
| } | |
| } | |
| /** | |
| * Immutable snapshot of the complete game state at the moment a callback fires. | |
| * | |
| * Every callback receives a [GameSnapshot] as its first argument, giving full | |
| * context — current time, score, special state, and all live notes — without | |
| * exposing any mutable internals of [GameEngine]. | |
| * | |
| * Because this is a true copy (not a reference to live state), it is safe to | |
| * store across frames (e.g. for replay systems or UI animations). The trade-off | |
| * is a small allocation per fired callback; for the typical event rate of a | |
| * rhythm game this is negligible. | |
| * | |
| * @param time Song position (ms) at the tick this snapshot was taken. | |
| * @param score Deep copy of [ScoreState] at the moment of the event. | |
| * @param special Deep copy of [SpecialState] at the moment of the event. | |
| * @param notesStates Shallow-immutable copy of the live note list. Each | |
| * [NoteState] inside is the same object the engine holds; | |
| * read its fields but do not mutate them from a callback. | |
| */ | |
| data class GameSnapshot( | |
| val time: Long, | |
| val score: ScoreState, | |
| val special: SpecialState, | |
| val notesStates: List<NoteState> | |
| ) | |
| // ───────────────────────────────────────────────────────────────────────────── | |
| // Callback type aliases | |
| // | |
| // Each alias gives a meaningful name to what would otherwise be an anonymous | |
| // function type. The first parameter of every callback is always [GameSnapshot], | |
| // providing full game context at the moment the event fired. | |
| // ───────────────────────────────────────────────────────────────────────────── | |
| /** Invoked when the player successfully hits a note. */ | |
| typealias NoteHitCallback = (snapshot: GameSnapshot, note: NoteState) -> Unit | |
| /** Invoked when a note's hit window expires without a hit. */ | |
| typealias NoteMissCallback = (snapshot: GameSnapshot, note: NoteState) -> Unit | |
| /** Invoked when the player's combo streak is reset to zero. */ | |
| typealias ComboBreakCallback = (snapshot: GameSnapshot) -> Unit | |
| /** Invoked the frame special mode transitions from inactive to active. */ | |
| typealias SpecialActivatedCallback = (snapshot: GameSnapshot) -> Unit | |
| /** Invoked the frame special mode transitions from active to inactive (energy depleted). */ | |
| typealias SpecialEndedCallback = (snapshot: GameSnapshot) -> Unit | |
| /** Invoked whenever the player gains special energy. Receives the updated energy value. */ | |
| typealias SpecialEnergyGainedCallback = (snapshot: GameSnapshot, energy: Int) -> Unit | |
| /** Invoked when a sustain note is held to full completion. */ | |
| typealias SustainCompletedCallback = (snapshot: GameSnapshot, note: NoteState) -> Unit | |
| /** Invoked when the player releases a sustain note before it finishes. */ | |
| typealias SustainBrokenCallback = (snapshot: GameSnapshot, note: NoteState) -> Unit | |
| /** | |
| * Central game-state machine for a rhythm game. | |
| * | |
| * [GameEngine] is responsible for one thing only: advancing the authoritative | |
| * state of every note and the special mechanic, one tick at a time, in response | |
| * to player input and elapsed time. It deliberately contains no rendering logic, | |
| * no audio logic, and no scoring formulas — those live in [ScoringSystem]. | |
| * | |
| * ### State-based contract | |
| * ``` | |
| * while (songPlaying) { | |
| * engine.tick(input, currentTimeMs) | |
| * render(engine.notesStates, engine.score, engine.special) | |
| * } | |
| * ``` | |
| * The engine never pushes events; consumers pull state after each [tick]. | |
| * Callbacks are an optional side-channel for fire-and-forget reactions | |
| * (sound effects, particle bursts, UI flashes) that need full game context. | |
| * | |
| * ### Registering callbacks | |
| * ```kotlin | |
| * engine.onNoteHit = { snapshot, note -> | |
| * println("Hit lane ${note.note.lane} — combo is now ${snapshot.score.combo}") | |
| * } | |
| * engine.onComboBroke = { snapshot -> | |
| * println("Combo broke at ${snapshot.time}ms, score was ${snapshot.score.score}") | |
| * } | |
| * ``` | |
| * All callbacks default to null (no-op). Setting a property to null removes | |
| * the listener. Each event fires at most once per occurrence per tick. | |
| * | |
| * Callbacks receive a [GameSnapshot] — an immutable deep copy of the full game | |
| * state at the exact moment the event occurred. This makes it safe to store | |
| * snapshots across frames without risk of observing stale or mutated data. | |
| * | |
| * Callbacks are invoked **synchronously** inside [tick], on the same thread | |
| * that calls it. Implementations must not call [tick] re-entrantly. | |
| * | |
| * ### Input consumption | |
| * [GameEngine] consumes [PlayerInput] bitmasks directly via [Int.forEachSetBit], | |
| * avoiding any [Set] or [List] allocation in the hot path. The only allocations | |
| * per tick are the [GameSnapshot] copies created when a callback fires, which | |
| * are proportional to game events (hits, misses), not to frame rate. | |
| * | |
| * ### Tick pipeline (order is load-bearing) | |
| * 1. [updateSpecial] — drain / activate special before any note logic. | |
| * 2. [spawnNotes] — promote upcoming notes into the live state list. | |
| * 3. [resolveMissedNotes] — close the window on notes the player didn't hit. | |
| * 4. [resolveInputs] — match player presses to live notes. | |
| * 5. [processSustain] — advance held sustain notes. | |
| * 6. [cleanup] — evict fully resolved notes from all collections. | |
| * | |
| * @param notes Complete, **time-sorted** list of notes in the chart. | |
| * @param hitWindow Asymmetric timing tolerance for a valid hit. | |
| * @param spawnAheadTime How many milliseconds ahead of [Note.hitTime] a note | |
| * enters [notesStates]. Must be large enough for the | |
| * renderer to show notes approaching the hit line. | |
| * Defaults to 0 (notes appear exactly at hit time). | |
| * @param scoringRules Strategy object that owns all scoring formulas. The | |
| * engine delegates combo and special bookkeeping to it | |
| * without storing any point values itself. | |
| */ | |
| class GameEngine( | |
| private val notes: List<Note>, | |
| private val hitWindow: HitWindow, | |
| private val spawnAheadTime: Long = 0L, | |
| private val scoringRules: ScoringRules = ScoringRules() | |
| ) { | |
| // ── Scoring delegate ───────────────────────────────────────────────────── | |
| // All numeric scoring math lives in ScoringSystem. GameEngine only calls | |
| // into it; it never reads or stores raw point values itself. | |
| private val scoring = ScoringSystem(scoringRules) | |
| // ── Time tracking ──────────────────────────────────────────────────────── | |
| /** Timestamp (ms) received in the most recent [tick] call. */ | |
| private var time: Long = 0 | |
| /** Elapsed milliseconds between the current and previous [tick] calls. */ | |
| private var dt: Long = 0 | |
| // ── Note-spawn cursor ──────────────────────────────────────────────────── | |
| /** Index into [notes] of the next note that has not yet been spawned. */ | |
| private var nextIndex = 0 | |
| // ── Special-phrase bookkeeping ─────────────────────────────────────────── | |
| /** | |
| * Indices (into [notes]) that are the *last* note of each special phrase. | |
| * Pre-computed once at construction to avoid repeated scans during gameplay. | |
| */ | |
| private val specialPhraseEndIndices: Set<Int> = buildSpecialPhraseEndIndices(notes) | |
| /** | |
| * Notes whose [Note.hitTime] falls before this timestamp are spawned with | |
| * [NoteState.specialDisabled] = true, because their phrase was broken. | |
| * Initialised to [Long.MIN_VALUE] so no note starts disabled. | |
| */ | |
| private var specialDisabledUntilTime: Long = Long.MIN_VALUE | |
| // ── Per-lane note queues ───────────────────────────────────────────────── | |
| /** | |
| * Lane → [ArrayDeque] of currently live [NoteState] objects for that lane, | |
| * in chronological order. | |
| * | |
| * [ArrayDeque] is used so that [cleanup] can remove spent notes from the | |
| * front in O(1) via [ArrayDeque.removeFirst], rather than an O(k) linear | |
| * scan. This is correct because notes within a lane always expire oldest-first. | |
| */ | |
| private val notesByLane = mutableMapOf<Int, ArrayDeque<NoteState>>() | |
| // ── Despawn-time cache ─────────────────────────────────────────────────── | |
| /** | |
| * Maps each live [Note] to its pre-computed despawn timestamp. | |
| * Avoids recalculating [despawnTime] on every frame for every live note. | |
| */ | |
| private val despawnTimeCache = mutableMapOf<Note, Long>() | |
| // ── Scratch buffer for resolveInputs ──────────────────────────────────── | |
| /** | |
| * Pre-allocated list reused every frame in [resolveInputs] to collect | |
| * successfully matched notes before processing them. Cleared at the start | |
| * of each call; never escapes the method, so no allocation occurs in the | |
| * hot path. | |
| */ | |
| private val hitNotesBuffer = ArrayList<NoteState>(8) | |
| // ── Public state ───────────────────────────────────────────────────────── | |
| /** | |
| * Live snapshot of every note currently visible to the engine. | |
| * | |
| * Exposed as an immutable [List] so external code (renderer, tests) can | |
| * read state but cannot accidentally mutate it. Only [GameEngine] writes | |
| * to the backing list. | |
| */ | |
| private val _notesStates = mutableListOf<NoteState>() | |
| val notesStates: List<NoteState> get() = _notesStates | |
| /** Accumulated score and combo counter. Mutated exclusively via [scoring]. */ | |
| val score = ScoreState() | |
| /** Special-mode energy and activation flags. */ | |
| val special = SpecialState() | |
| // ════════════════════════════════════════════════════════════════════════ | |
| // Callbacks | |
| // | |
| // All callbacks are nullable — null means no listener, which costs nothing | |
| // beyond a single null-check. Set to null to unregister at any time. | |
| // | |
| // Every callback receives a [GameSnapshot] as its first argument: an | |
| // immutable deep copy of the full game state at the exact moment the event | |
| // occurred. Event-specific arguments follow as additional parameters. | |
| // | |
| // Callbacks are invoked synchronously inside [tick]. Do not call [tick] | |
| // re-entrantly from within a callback. | |
| // ════════════════════════════════════════════════════════════════════════ | |
| /** Called once for each note successfully hit this tick. */ | |
| var onNoteHit: NoteHitCallback? = null | |
| /** Called once for each note newly missed this tick. */ | |
| var onNoteMiss: NoteMissCallback? = null | |
| /** | |
| * Called when the player's combo streak is broken. | |
| * May fire more than once per tick if both a miss and a wrong input occur | |
| * in the same frame (each independently resets the combo). | |
| */ | |
| var onComboBroke: ComboBreakCallback? = null | |
| /** Called the frame special mode is activated. Fires at most once per activation. */ | |
| var onSpecialActivated: SpecialActivatedCallback? = null | |
| /** Called the frame special mode ends due to energy depletion. */ | |
| var onSpecialEnded: SpecialEndedCallback? = null | |
| /** | |
| * Called whenever the player gains special energy (phrase completion). | |
| * The [energy] parameter reflects the updated value after the gain. | |
| */ | |
| var onSpecialEnergyGained: SpecialEnergyGainedCallback? = null | |
| /** Called when a sustain note is held to full completion. */ | |
| var onSustainCompleted: SustainCompletedCallback? = null | |
| /** Called when the player releases a sustain note before it finishes. */ | |
| var onSustainBroken: SustainBrokenCallback? = null | |
| // ════════════════════════════════════════════════════════════════════════ | |
| // Public API | |
| // ════════════════════════════════════════════════════════════════════════ | |
| /** | |
| * Advances the engine by one simulation step. | |
| * | |
| * Must be called exactly once per rendered frame. [currentTime] must be | |
| * monotonically non-decreasing across calls (e.g. milliseconds since the | |
| * song started). Passing a timestamp smaller than the previous one produces | |
| * undefined behaviour. | |
| * | |
| * After this returns, [notesStates], [score], and [special] reflect the | |
| * fully updated state for the current frame and are safe to read. Any | |
| * registered callbacks will have already been invoked before this returns. | |
| * | |
| * @param input Zero-allocation bitmask snapshot of player input for this frame. | |
| * @param currentTime Monotonically increasing song position in milliseconds. | |
| */ | |
| fun tick(input: PlayerInput, currentTime: Long) { | |
| dt = currentTime - time | |
| time = currentTime | |
| updateSpecial(input) | |
| spawnNotes() | |
| resolveMissedNotes() | |
| resolveInputs(input) | |
| processSustain(input) | |
| cleanup() | |
| } | |
| // ════════════════════════════════════════════════════════════════════════ | |
| // Pipeline steps | |
| // ════════════════════════════════════════════════════════════════════════ | |
| /** | |
| * Handles special-mode activation and per-frame energy drain. | |
| * | |
| * If the player requests activation ([PlayerInput.activateSpecial]) and the | |
| * [ScoringSystem] considers the current energy sufficient, special mode is | |
| * enabled and [onSpecialActivated] fires. While active, | |
| * [ScoringSystem.updateSpecialDrain] depletes energy each tick; when energy | |
| * runs out [onSpecialEnded] fires. Phrase-tracking flags are suppressed | |
| * while special is active. When inactive the drain accumulator is reset so | |
| * partial-drain progress does not carry over between activations. | |
| */ | |
| private fun updateSpecial(input: PlayerInput) { | |
| val wasActive = special.active | |
| if (input.activateSpecial && scoring.canActivateSpecial(special.energy)) { | |
| special.active = true | |
| } | |
| if (special.active) { | |
| if (!wasActive) onSpecialActivated?.invoke(snapshot()) | |
| val wasEnergyPositive = special.energy > 0 | |
| scoring.updateSpecialDrain(special, dt) | |
| if (wasEnergyPositive && special.energy <= 0) onSpecialEnded?.invoke(snapshot()) | |
| special.inSequence = false | |
| special.sequenceBroken = false | |
| } else { | |
| special.drainAccumulator = 0.0 | |
| } | |
| } | |
| /** | |
| * Promotes notes from [notes] into [_notesStates] when their spawn time arrives. | |
| * | |
| * A note is spawned when `note.hitTime <= time + spawnAheadTime`. The lead | |
| * time given by [spawnAheadTime] lets the renderer show notes travelling | |
| * toward the hit line before they actually need to be hit. | |
| * | |
| * Each spawned note is also inserted at the back of its lane's [ArrayDeque] | |
| * (preserving chronological order) and its despawn timestamp is cached. | |
| */ | |
| private fun spawnNotes() { | |
| while (nextIndex < notes.size && notes[nextIndex].hitTime <= time + spawnAheadTime) { | |
| val note = notes[nextIndex] | |
| val state = NoteState( | |
| note = note, | |
| index = nextIndex, | |
| specialDisabled = note.isSpecial && note.hitTime < specialDisabledUntilTime, | |
| specialPhraseEnd = nextIndex in specialPhraseEndIndices | |
| ) | |
| _notesStates.add(state) | |
| notesByLane.getOrPut(note.lane) { ArrayDeque() }.addLast(state) | |
| despawnTimeCache[note] = despawnTime(note) | |
| nextIndex++ | |
| } | |
| } | |
| /** | |
| * Closes the hit window on notes the player failed to hit in time. | |
| * | |
| * A note is missed when `time - note.hitTime > hitWindow.late`. Only notes | |
| * newly missed *this frame* are collected; [onNoteMiss] and [handleNoteMiss] | |
| * are called exclusively on that subset to prevent repeated side-effects on | |
| * notes already processed in prior ticks. | |
| * | |
| * Returns early if there are no live notes, avoiding unnecessary iteration. | |
| */ | |
| private fun resolveMissedNotes() { | |
| if (_notesStates.isEmpty()) return | |
| var hadMiss = false | |
| for (state in _notesStates) { | |
| if (!state.hit && !state.missed && (time - state.note.hitTime) > hitWindow.late) { | |
| state.missed = true | |
| hadMiss = true | |
| } | |
| } | |
| if (!hadMiss) return | |
| resetCombo() | |
| for (state in _notesStates) { | |
| if (state.missed && !state.hit) { | |
| // Guard: only process notes that were just marked missed this frame. | |
| // Because cleanup has not run yet, previously missed notes are still | |
| // in the list; re-check the hitTime boundary to skip them. | |
| if ((time - state.note.hitTime) <= hitWindow.late + dt) { | |
| onNoteMiss?.invoke(snapshot(), state) | |
| handleNoteMiss(state) | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Matches player fret presses to the closest eligible note in each lane. | |
| * | |
| * Iterates [PlayerInput.justPressedFrets] via [Int.forEachSetBit] — a | |
| * zero-allocation bit-scan loop that visits only the set bits. A press | |
| * with no matching note is flagged as wrong input. After all lanes are | |
| * processed, valid hits are registered and any wrong input resets the combo. | |
| * | |
| * Uses [hitNotesBuffer] as a pre-allocated scratch buffer to avoid creating | |
| * a new list per frame. | |
| * | |
| * **Chord behaviour**: if the player presses multiple frets and only a subset | |
| * have matching notes, the matched lanes register hits while the unmatched | |
| * lanes cause a combo break. Adjust if a stricter all-or-nothing rule is needed. | |
| */ | |
| private fun resolveInputs(input: PlayerInput) { | |
| if (input.justPressedFrets == 0) return | |
| hitNotesBuffer.clear() | |
| var hasWrongInput = false | |
| input.justPressedFrets.forEachSetBit { lane -> | |
| val noteState = findClosestPendingNoteForLane(lane) | |
| if (noteState == null) { | |
| hasWrongInput = true | |
| } else { | |
| noteState.hit = true | |
| noteState.holding = noteState.note.duration > 0 | |
| hitNotesBuffer.add(noteState) | |
| } | |
| } | |
| for (i in hitNotesBuffer.indices) { | |
| val noteState = hitNotesBuffer[i] | |
| scoring.registerHit(score, special.active) | |
| onNoteHit?.invoke(snapshot(), noteState) | |
| handleNoteHit(noteState) | |
| } | |
| if (hasWrongInput) resetCombo() | |
| } | |
| /** | |
| * Returns the unhit, unmatched note in [lane] closest to [time] and still | |
| * within [hitWindow]. | |
| * | |
| * Searches only [notesByLane] — O(k) where k is the number of live notes | |
| * in that lane, typically 1–3. Tie-breaking prefers the smallest timing | |
| * delta, then the earliest hit time. | |
| * | |
| * Returns null if no eligible note exists for the given lane. | |
| */ | |
| private fun findClosestPendingNoteForLane(lane: Int): NoteState? { | |
| val candidates = notesByLane[lane] ?: return null | |
| return candidates | |
| .asSequence() | |
| .filter { !it.hit && !it.missed && hitWindow.contains(time - it.note.hitTime) } | |
| .minWithOrNull(compareBy({ Math.abs(time - it.note.hitTime) }, { it.note.hitTime })) | |
| } | |
| /** | |
| * Updates special-phrase progress when a note is successfully hit. | |
| * | |
| * Phrase tracking is skipped entirely while special mode is active. | |
| * | |
| * - **Special note, not disabled**: starts or continues the current phrase. | |
| * If this note is the phrase-end and the sequence is unbroken, energy is | |
| * gained and [onSpecialEnergyGained] fires. | |
| * - **Special note, disabled**: no-op — disabled notes grant nothing. | |
| * - **Regular note**: if an unbroken special sequence was in progress, | |
| * energy is gained and [onSpecialEnergyGained] fires; the sequence ends. | |
| */ | |
| private fun handleNoteHit(noteState: NoteState) { | |
| if (special.active) return | |
| when { | |
| noteState.note.isSpecial && !noteState.specialDisabled -> { | |
| if (!special.inSequence) { | |
| special.inSequence = true | |
| special.sequenceBroken = false | |
| } | |
| if (noteState.specialPhraseEnd && !special.sequenceBroken) { | |
| special.energy = scoring.gainSpecialEnergy(special.energy) | |
| onSpecialEnergyGained?.invoke(snapshot(), special.energy) | |
| special.inSequence = false | |
| special.sequenceBroken = false | |
| } | |
| } | |
| !noteState.note.isSpecial -> { | |
| if (special.inSequence && !special.sequenceBroken) { | |
| special.energy = scoring.gainSpecialEnergy(special.energy) | |
| onSpecialEnergyGained?.invoke(snapshot(), special.energy) | |
| } | |
| special.inSequence = false | |
| special.sequenceBroken = false | |
| } | |
| // isSpecial && specialDisabled → no-op | |
| } | |
| } | |
| /** | |
| * Handles the consequences of missing a special note. | |
| * | |
| * Missing a special note breaks the current phrase and disables all remaining | |
| * notes in the same phrase. The phrase boundary is found by walking forward | |
| * from [NoteState.index] in [notes] until the first non-special note — | |
| * O(phrase length), not O(total notes). | |
| * | |
| * Non-special notes and already-disabled notes are ignored. | |
| */ | |
| private fun handleNoteMiss(noteState: NoteState) { | |
| if (!noteState.note.isSpecial || noteState.specialDisabled) return | |
| special.sequenceBroken = true | |
| var sequenceEndTime = Long.MAX_VALUE | |
| for (i in noteState.index + 1 until notes.size) { | |
| if (!notes[i].isSpecial) { | |
| sequenceEndTime = notes[i].hitTime | |
| break | |
| } | |
| } | |
| specialDisabledUntilTime = sequenceEndTime | |
| _notesStates.forEach { state -> | |
| if (!state.hit && !state.missed && | |
| state.note.isSpecial && | |
| state.note.hitTime >= noteState.note.hitTime && | |
| state.note.hitTime < sequenceEndTime | |
| ) { | |
| state.specialDisabled = true | |
| } | |
| } | |
| } | |
| /** | |
| * Scores and advances the progress of all currently held sustain notes. | |
| * | |
| * Sustain state is updated by checking [PlayerInput.pressedFrets] and | |
| * [PlayerInput.justReleasedFrets] bitmasks directly via [Int.isLaneBitSet], | |
| * avoiding any collection lookup. For each note in a [NoteState.holding] state: | |
| * - **Complete**: progress reached full duration — hold ends cleanly and | |
| * [onSustainCompleted] fires. | |
| * - **Released**: the player released the lane this frame or is no longer | |
| * pressing it — hold ends, [NoteState.sustainBroken] is set, and | |
| * [onSustainBroken] fires. | |
| * - **Ongoing**: progress advances by `min(dt, remaining)` milliseconds and | |
| * the delta is forwarded to [ScoringSystem.registerSustain]. | |
| */ | |
| private fun processSustain(input: PlayerInput) { | |
| _notesStates.forEach { state -> | |
| if (!state.holding) return@forEach | |
| val remaining = state.note.duration - state.sustainProgress | |
| if (remaining <= 0.0) { | |
| state.holding = false | |
| onSustainCompleted?.invoke(snapshot(), state) | |
| return@forEach | |
| } | |
| // Bitmask check: test the single bit for this note's lane. | |
| val laneBit = 1 shl state.note.lane | |
| val released = (input.justReleasedFrets and laneBit) != 0 | |
| val stillHeld = (input.pressedFrets and laneBit) != 0 | |
| if (released || !stillHeld) { | |
| state.holding = false | |
| if (state.sustainProgress < state.note.duration) { | |
| state.sustainBroken = true | |
| onSustainBroken?.invoke(snapshot(), state) | |
| } | |
| return@forEach | |
| } | |
| val delta = minOf(dt.toDouble(), remaining) | |
| scoring.registerSustain(score, delta, special.active) | |
| state.sustainProgress += delta | |
| if (state.sustainProgress >= state.note.duration) { | |
| state.holding = false | |
| onSustainCompleted?.invoke(snapshot(), state) | |
| } | |
| } | |
| } | |
| /** | |
| * Evicts fully resolved notes from [_notesStates], [notesByLane], and | |
| * [despawnTimeCache] once their post-resolution display window has elapsed. | |
| * | |
| * A note is eligible for eviction when it is resolved (hit or missed) *and* | |
| * `time > despawnTime`. The extra window beyond resolution lets the renderer | |
| * finish any hit/miss animation before the state disappears. | |
| * | |
| * Because notes within a lane are always resolved oldest-first, spent notes | |
| * are always at the front of their lane's [ArrayDeque], making | |
| * [ArrayDeque.removeFirst] O(1). | |
| */ | |
| private fun cleanup() { | |
| val iterator = _notesStates.iterator() | |
| while (iterator.hasNext()) { | |
| val state = iterator.next() | |
| val despawn = despawnTimeCache[state.note] ?: despawnTime(state.note) | |
| if ((state.hit || state.missed) && time > despawn) { | |
| iterator.remove() | |
| notesByLane[state.note.lane]?.let { deque -> | |
| if (deque.firstOrNull() === state) deque.removeFirst() | |
| } | |
| despawnTimeCache.remove(state.note) | |
| } | |
| } | |
| } | |
| // ════════════════════════════════════════════════════════════════════════ | |
| // Helpers | |
| // ════════════════════════════════════════════════════════════════════════ | |
| /** | |
| * Builds an immutable [GameSnapshot] from the current engine state. | |
| * | |
| * Called immediately before each callback invocation so the snapshot | |
| * captures the state *after* the event that triggered it (e.g. after a | |
| * hit is registered, the snapshot already reflects the updated combo). | |
| * [ScoreState] and [SpecialState] are copied via their `copy()` functions; | |
| * [notesStates] is wrapped in an unmodifiable list view — its [NoteState] | |
| * elements are the live objects, so their fields should be read but not | |
| * mutated from within a callback. | |
| */ | |
| private fun snapshot() = GameSnapshot( | |
| time = time, | |
| score = score.copy(), | |
| special = special.copy(), | |
| notesStates = _notesStates.toList() | |
| ) | |
| /** | |
| * Resets the combo via [ScoringSystem] and fires [onComboBroke]. | |
| * | |
| * Centralised so every combo-break site (missed notes, wrong inputs) goes | |
| * through one place, guaranteeing the callback always accompanies the reset. | |
| */ | |
| private fun resetCombo() { | |
| scoring.resetCombo(score) | |
| onComboBroke?.invoke(snapshot()) | |
| } | |
| /** | |
| * Computes the timestamp after which a resolved note can be safely removed. | |
| * | |
| * The note is kept alive long enough for the renderer to display a hit or | |
| * miss animation: at minimum 1 000 ms (for tap notes), or the full sustain | |
| * [Note.duration] if longer, plus an additional 1 000 ms of post-event | |
| * display time. | |
| */ | |
| private fun despawnTime(note: Note): Long { | |
| val visibleLifetime = maxOf(1_000L, note.duration) | |
| return note.hitTime + visibleLifetime + 1_000L | |
| } | |
| /** | |
| * Pre-computes the set of [notes] indices that are the last note of each | |
| * special phrase. | |
| * | |
| * A special note at index `i` is a phrase-end when `notes[i + 1]` either | |
| * does not exist or is not a special note. Called once in the constructor; | |
| * the result is immutable for the lifetime of the engine. | |
| */ | |
| private fun buildSpecialPhraseEndIndices(notes: List<Note>): Set<Int> { | |
| if (notes.isEmpty()) return emptySet() | |
| return buildSet { | |
| notes.forEachIndexed { index, note -> | |
| if (!note.isSpecial) return@forEachIndexed | |
| val next = notes.getOrNull(index + 1) | |
| if (next == null || !next.isSpecial) add(index) | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * | |
| * Represents all data required to play a song. | |
| * | |
| * @property notes List of parsed notes sorted by time. | |
| * | |
| * @property musicFileName Optional reference to the audio file associated with the chart. | |
| * | |
| * @property lengthMs Optional total song length in milliseconds. | |
| * | |
| * Notes: | |
| * | |
| * - Some chart formats may omit metadata, so nullable fields are expected. | |
| * | |
| * - The engine should not rely strictly on [lengthMs]; playback systems may define their own | |
| * timing. | |
| */ | |
| data class SongData(val notes: List<Note>, val musicFileName: String?, val lengthMs: Long?) | |
| /** | |
| * | |
| * Utility object responsible for parsing song chart data from XML. | |
| * | |
| * Expected XML structure (loosely defined): | |
| * | |
| * - Root element containing: | |
| * | |
| * - Multiple <Note> elements | |
| * | |
| * - Optional <Properties> section | |
| * | |
| * Design goals: | |
| * | |
| * - Be tolerant to malformed or incomplete data | |
| * | |
| * - Skip invalid notes instead of failing the entire parsing process | |
| * | |
| * - Convert all time units to milliseconds for engine compatibility | |
| */ | |
| object SongXmlParser { | |
| /** | |
| * | |
| * Parses an XML input stream into a [SongData] object. | |
| * | |
| * Processing steps: | |
| * | |
| * 1. Build DOM document | |
| * | |
| * 2. Normalize XML structure | |
| * | |
| * 3. Extract notes | |
| * | |
| * 4. Extract metadata properties | |
| * | |
| * 5. Sort notes by time (guarantees engine correctness) | |
| * | |
| * @param input Input stream containing XML chart data. | |
| * | |
| * @return Parsed [SongData] ready for use in the game engine. | |
| * | |
| * Important: | |
| * | |
| * - This method does not close the input stream. | |
| * | |
| * - Any XML parsing exception will propagate to the caller. | |
| */ | |
| fun parse(input: InputStream): SongData { | |
| val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(input) | |
| val root = doc.documentElement | |
| root.normalize() | |
| val notes = parseNotes(root) | |
| val (musicFileName, lengthMs) = parseProperties(root) | |
| return SongData( | |
| notes = notes.sortedBy { it.hitTime }, | |
| musicFileName = musicFileName, | |
| lengthMs = lengthMs | |
| ) | |
| } | |
| // ==================== NOTES PARSING ==================== | |
| /** | |
| * | |
| * Extracts all <Note> elements from the XML root. | |
| * | |
| * Expected attributes per note: | |
| * | |
| * - time (seconds, required) | |
| * | |
| * - duration (seconds, optional, defaults to 0) | |
| * | |
| * - track (lane index, required) | |
| * | |
| * - special (optional flag, "1" = true) | |
| * | |
| * Error handling strategy: | |
| * | |
| * - Invalid or missing required fields cause the note to be skipped | |
| * | |
| * - Optional fields fallback to safe defaults | |
| * | |
| * Time conversion: | |
| * | |
| * - Input is in seconds (floating point) | |
| * | |
| * - Internally converted to milliseconds (Long) | |
| * | |
| * @param root Root XML element. | |
| * | |
| * @return List of parsed [Note] objects (unsorted). | |
| */ | |
| private fun parseNotes(root: Element): List<Note> { | |
| val noteList = root.getElementsByTagName("Note") | |
| val result = mutableListOf<Note>() | |
| for (i in 0 until noteList.length) { | |
| val node = noteList.item(i) as? Element ?: continue | |
| /** | |
| * | |
| * Required: time (seconds) | |
| * | |
| * If invalid, skip the note entirely. | |
| */ | |
| val timeSec = node.getAttribute("time").toDoubleOrNull() ?: continue | |
| /** | |
| * | |
| * Optional: duration (seconds) | |
| * | |
| * Defaults to 0 (tap note). | |
| */ | |
| val durationSec = node.getAttribute("duration").toDoubleOrNull() ?: 0.0 | |
| /** | |
| * | |
| * Required: track (lane index) | |
| * | |
| * If invalid, skip the note. | |
| */ | |
| val lane = node.getAttribute("track").toIntOrNull() ?: continue | |
| /** | |
| * | |
| * Optional: special flag | |
| * | |
| * Convention: "1" means true, anything else is false. | |
| */ | |
| val isSpecial = node.getAttribute("special") == "1" | |
| result.add( | |
| Note( | |
| hitTime = (timeSec * 1000).toLong(), | |
| lane = lane, | |
| duration = (durationSec * 1000).toLong(), | |
| isSpecial = isSpecial | |
| ) | |
| ) | |
| } | |
| return result | |
| } | |
| // ==================== METADATA PARSING ==================== | |
| /** | |
| * | |
| * Extracts optional metadata from the <Properties> section. | |
| * | |
| * Expected structure: | |
| * | |
| * <Properties> | |
| * | |
| * <MusicFileName>...</MusicFileName> | |
| * | |
| * <Length>...</Length> <!-- seconds --> | |
| * | |
| * </Properties> | |
| * | |
| * Behavior: | |
| * | |
| * - If <Properties> is missing, returns null values | |
| * | |
| * - Missing individual fields are also treated as null | |
| * | |
| * @param root Root XML element. | |
| * | |
| * @return Pair of (musicFileName, lengthMs) | |
| */ | |
| private fun parseProperties(root: Element): Pair<String?, Long?> { | |
| val propsList = root.getElementsByTagName("Properties") | |
| if (propsList.length == 0) return null to null | |
| val props = propsList.item(0) as? Element ?: return null to null | |
| /** | |
| * | |
| * Optional music file reference. | |
| */ | |
| val musicFileName = props.getElementsByTagName("MusicFileName").item(0)?.textContent | |
| /** | |
| * | |
| * Optional song length in seconds. | |
| * | |
| * Converted to milliseconds if valid. | |
| */ | |
| val lengthSec = props.getElementsByTagName("Length").item(0)?.textContent?.toDoubleOrNull() | |
| val lengthMs = lengthSec?.let { (it * 1000).toLong() } | |
| return musicFileName to lengthMs | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment