Refactor: Timer-basierte Ticks durch Event-Queue ersetzen (Crops, Seedlings, TileRecovery) #36

Open
opened 2026-03-23 20:27:49 +00:00 by claude · 0 comments
Collaborator

Hintergrund & Problem

Aktuell werden in StateManager drei Methoden aufgerufen, die jeden Frame alle vorhandenen Einträge iterieren, nur um deren Countdown-Timer um delta zu reduzieren:

tickCrops(delta)      → iteriert ALLE crops
tickSeedlings(delta)  → iteriert ALLE treeSeedlings
tickTileRecovery(delta) → iteriert ALLE tileRecovery-Einträge

Das bedeutet: selbst wenn gerade kein einziges Crop kurz davor ist zu wachsen, wird trotzdem jedes Frame Object.values(this.state.world.crops) aufgerufen — ein neues Array wird alloziert, jedes Element wird angefasst, nichts passiert. Bei wenigen Crops ist das harmlos. Wenn aber viele Crops/Seedlings gleichzeitig wachsen (z.B. Förster pflanzt massenhaft Setzlinge), wächst der Aufwand linear mit der Anzahl der Einträge. Das ist unnötig.

Der Kern des Problems: Wir pollen jeden Frame, obwohl wir im Voraus wissen, wann sich etwas ändern wird.


Das bessere Pattern: Absoluter Zeitstempel + sortierter Event-Queue

Anstatt einen Countdown rückwärts zu zählen (stageTimerMs -= delta), speichern wir wann ein Eintrag als nächstes fällig ist (growsAt: number). StateManager hält einen akkumulierten gameTime-Wert. Jedes Frame wird nur gefragt: „Welche Einträge sind jetzt fällig?" — das sind nur die am Anfang der sortierten Queue.

Visualisierung

Heute (Polling):

Frame 1:  crop_a: 4800ms  crop_b: 1200ms  crop_c: 9100ms  → alle angefasst
Frame 2:  crop_a: 4783ms  crop_b: 1183ms  crop_c: 9083ms  → alle angefasst
...
Frame 72: crop_a: 3600ms  crop_b:    0ms  crop_c: 7900ms  → crop_b fällig

Neu (Event-Queue):

Queue (sortiert nach growsAt): [ crop_b: 1200, crop_a: 4800, crop_c: 9100 ]

Frame 1:   gameTime=17   → queue[0].growsAt=1200 > 17  → nichts zu tun
Frame 2:   gameTime=33   → queue[0].growsAt=1200 > 33  → nichts zu tun
...
Frame 72:  gameTime=1201 → queue[0].growsAt=1200 ≤ 1201 → crop_b verarbeiten, re-enqueue mit growsAt=1200+stageTime

Bei 100 Crops: heute 100 Checks pro Frame. Neu: 0–1 Checks pro Frame (nur die fälligen).


Betroffene Stellen

1. CropState (types.ts)

// Heute
stageTimerMs: number   // Countdown bis nächste Stage

// Neu
growsAt: number        // absoluter gameTime-Wert, wann nächste Stage

2. TreeSeedlingState (types.ts)

// Heute
stageTimerMs: number

// Neu
growsAt: number

3. tileRecovery (GameState in types.ts / StateManager)

// Heute
tileRecovery: Record<string, number>   // key → verbleibende ms

// Neu
tileRecovery: Record<string, number>   // key → growsAt (absoluter Zeitstempel)

4. StateManager

  • gameTime: number — akkumulierter Spielzeit-Wert, wird durch alle drei Tick-Methoden inkrementiert
  • Drei sortierte In-Memory-Queues (nicht persistiert, beim Laden aufgebaut):
    • cropQueue: Array<{ id: string; growsAt: number }>
    • seedlingQueue: Array<{ id: string; growsAt: number }>
    • tileRecoveryQueue: Array<{ key: string; growsAt: number }>
  • Neue Hilfsmethoden:
    • enqueueCrop(id) / enqueueSeedling(id) / enqueueTileRecovery(key)
    • rebuildQueues() — wird beim Laden aufgerufen
  • tickCrops(gameTime) → drain queue front, kein Object.values() mehr
  • tickSeedlings(gameTime) → gleich
  • tickTileRecovery(gameTime) → gleich

5. Save-Kompatibilität

Ältere Saves haben stageTimerMs statt growsAt. Beim Laden prüfen:

if (crop.growsAt === undefined) {
  crop.growsAt = this.gameTime + (crop as any).stageTimerMs
}

6. Watering-Sonderfall (FarmingSystem)

Crops können bewässert werden (watered: true), was die Wachstumsgeschwindigkeit verdoppelt. Heute: delta * (crop.watered ? 2 : 1). Neu: beim Bewässern growsAt neu berechnen:

// crop.growsAt war: gameTime + restZeit
// nach Bewässern: gameTime + restZeit/2
crop.growsAt = gameTime + (crop.growsAt - gameTime) / 2

Die Queue muss danach neu sortiert werden (oder das betroffene Element neu eingereiht).


Implementierungsplan

Schritt 1 — gameTime-Akkumulator in StateManager

  • Feld private gameTime = 0 hinzufügen
  • advanceTime(delta: number) oder direkt in den bestehenden Tick-Methoden erhöhen

Schritt 2 — Types anpassen

  • CropState: stageTimerMsgrowsAt
  • TreeSeedlingState: stageTimerMsgrowsAt
  • Beide Felder optional machen für Übergangszeit

Schritt 3 — Queue-Infrastruktur in StateManager

  • Drei In-Memory-Arrays als sortierte Queues
  • rebuildQueues() — iteriert gespeicherten State, baut alle drei Queues auf (nur beim Laden/Start)
  • insertSorted(queue, entry) — O(log n) Binary-Search-Insert

Schritt 4 — Tick-Methoden umschreiben

tickCrops(gameTime: number): string[] {
  const advanced: string[] = []
  while (this.cropQueue.length > 0 && this.cropQueue[0].growsAt <= gameTime) {
    const { id } = this.cropQueue.shift()!
    const crop = this.state.world.crops[id]
    if (!crop || crop.stage >= crop.maxStage) continue
    crop.stage++
    if (crop.stage < crop.maxStage) {
      crop.growsAt = gameTime + CROP_CONFIGS[crop.kind].stageTimeMs
      this.insertSorted(this.cropQueue, { id, growsAt: crop.growsAt })
    }
    advanced.push(id)
  }
  return advanced
}

Schritt 5 — Aufrufer anpassen

  • GameScene.update(): stateManager.advanceTime(delta) aufrufen, dann Tick-Methoden ohne delta
  • FarmingSystem: Watering-Logik auf neue growsAt-Berechnung umstellen

Schritt 6 — Save/Load

  • StateManager.load(): nach dem Laden rebuildQueues() aufrufen
  • Migrations-Check für alte stageTimerMs-Felder

Was wir NICHT ändern

  • Die Persistierungslogik bleibt im Wesentlichen gleich — growsAt ist ein einfacher number wie stageTimerMs
  • Kein Breaking-Change an der Adapter-API
  • Kein neues Abhängigkeit

Offene Fragen für die Diskussion

  1. Soll gameTime über Saves hinweg akkumulieren (d.h. persistiert werden), oder bei jedem Spielstart bei 0 anfangen und growsAt relativ zu „Ladezeit + Rest" initialisieren?
  2. Soll tileRecovery auch umgestellt werden, oder erstmal nur Crops & Seedlings (da die häufiger/zahlreicher sind)?
  3. Soll der Watering-Sonderfall direkt mitgemacht werden, oder erst in einem Follow-up?
## Hintergrund & Problem Aktuell werden in `StateManager` drei Methoden aufgerufen, die **jeden Frame alle vorhandenen Einträge iterieren**, nur um deren Countdown-Timer um `delta` zu reduzieren: ``` tickCrops(delta) → iteriert ALLE crops tickSeedlings(delta) → iteriert ALLE treeSeedlings tickTileRecovery(delta) → iteriert ALLE tileRecovery-Einträge ``` Das bedeutet: selbst wenn gerade kein einziges Crop kurz davor ist zu wachsen, wird trotzdem jedes Frame `Object.values(this.state.world.crops)` aufgerufen — ein neues Array wird alloziert, jedes Element wird angefasst, nichts passiert. Bei wenigen Crops ist das harmlos. Wenn aber viele Crops/Seedlings gleichzeitig wachsen (z.B. Förster pflanzt massenhaft Setzlinge), wächst der Aufwand linear mit der Anzahl der Einträge. Das ist unnötig. **Der Kern des Problems:** Wir pollen jeden Frame, obwohl wir im Voraus wissen, wann sich etwas ändern wird. --- ## Das bessere Pattern: Absoluter Zeitstempel + sortierter Event-Queue Anstatt einen Countdown rückwärts zu zählen (`stageTimerMs -= delta`), speichern wir **wann** ein Eintrag als nächstes fällig ist (`growsAt: number`). StateManager hält einen akkumulierten `gameTime`-Wert. Jedes Frame wird nur gefragt: *„Welche Einträge sind jetzt fällig?"* — das sind nur die am Anfang der sortierten Queue. ### Visualisierung **Heute (Polling):** ``` Frame 1: crop_a: 4800ms crop_b: 1200ms crop_c: 9100ms → alle angefasst Frame 2: crop_a: 4783ms crop_b: 1183ms crop_c: 9083ms → alle angefasst ... Frame 72: crop_a: 3600ms crop_b: 0ms crop_c: 7900ms → crop_b fällig ``` **Neu (Event-Queue):** ``` Queue (sortiert nach growsAt): [ crop_b: 1200, crop_a: 4800, crop_c: 9100 ] Frame 1: gameTime=17 → queue[0].growsAt=1200 > 17 → nichts zu tun Frame 2: gameTime=33 → queue[0].growsAt=1200 > 33 → nichts zu tun ... Frame 72: gameTime=1201 → queue[0].growsAt=1200 ≤ 1201 → crop_b verarbeiten, re-enqueue mit growsAt=1200+stageTime ``` Bei 100 Crops: heute 100 Checks pro Frame. Neu: 0–1 Checks pro Frame (nur die fälligen). --- ## Betroffene Stellen ### 1. `CropState` (types.ts) ```typescript // Heute stageTimerMs: number // Countdown bis nächste Stage // Neu growsAt: number // absoluter gameTime-Wert, wann nächste Stage ``` ### 2. `TreeSeedlingState` (types.ts) ```typescript // Heute stageTimerMs: number // Neu growsAt: number ``` ### 3. `tileRecovery` (GameState in types.ts / StateManager) ```typescript // Heute tileRecovery: Record<string, number> // key → verbleibende ms // Neu tileRecovery: Record<string, number> // key → growsAt (absoluter Zeitstempel) ``` ### 4. `StateManager` - `gameTime: number` — akkumulierter Spielzeit-Wert, wird durch alle drei Tick-Methoden inkrementiert - Drei sortierte In-Memory-Queues (nicht persistiert, beim Laden aufgebaut): - `cropQueue: Array<{ id: string; growsAt: number }>` - `seedlingQueue: Array<{ id: string; growsAt: number }>` - `tileRecoveryQueue: Array<{ key: string; growsAt: number }>` - Neue Hilfsmethoden: - `enqueueCrop(id)` / `enqueueSeedling(id)` / `enqueueTileRecovery(key)` - `rebuildQueues()` — wird beim Laden aufgerufen - `tickCrops(gameTime)` → drain queue front, kein `Object.values()` mehr - `tickSeedlings(gameTime)` → gleich - `tickTileRecovery(gameTime)` → gleich ### 5. Save-Kompatibilität Ältere Saves haben `stageTimerMs` statt `growsAt`. Beim Laden prüfen: ```typescript if (crop.growsAt === undefined) { crop.growsAt = this.gameTime + (crop as any).stageTimerMs } ``` ### 6. Watering-Sonderfall (FarmingSystem) Crops können bewässert werden (`watered: true`), was die Wachstumsgeschwindigkeit verdoppelt. Heute: `delta * (crop.watered ? 2 : 1)`. Neu: beim Bewässern `growsAt` neu berechnen: ```typescript // crop.growsAt war: gameTime + restZeit // nach Bewässern: gameTime + restZeit/2 crop.growsAt = gameTime + (crop.growsAt - gameTime) / 2 ``` Die Queue muss danach neu sortiert werden (oder das betroffene Element neu eingereiht). --- ## Implementierungsplan ### Schritt 1 — `gameTime`-Akkumulator in StateManager - Feld `private gameTime = 0` hinzufügen - `advanceTime(delta: number)` oder direkt in den bestehenden Tick-Methoden erhöhen ### Schritt 2 — Types anpassen - `CropState`: `stageTimerMs` → `growsAt` - `TreeSeedlingState`: `stageTimerMs` → `growsAt` - Beide Felder optional machen für Übergangszeit ### Schritt 3 — Queue-Infrastruktur in StateManager - Drei In-Memory-Arrays als sortierte Queues - `rebuildQueues()` — iteriert gespeicherten State, baut alle drei Queues auf (nur beim Laden/Start) - `insertSorted(queue, entry)` — O(log n) Binary-Search-Insert ### Schritt 4 — Tick-Methoden umschreiben ```typescript tickCrops(gameTime: number): string[] { const advanced: string[] = [] while (this.cropQueue.length > 0 && this.cropQueue[0].growsAt <= gameTime) { const { id } = this.cropQueue.shift()! const crop = this.state.world.crops[id] if (!crop || crop.stage >= crop.maxStage) continue crop.stage++ if (crop.stage < crop.maxStage) { crop.growsAt = gameTime + CROP_CONFIGS[crop.kind].stageTimeMs this.insertSorted(this.cropQueue, { id, growsAt: crop.growsAt }) } advanced.push(id) } return advanced } ``` ### Schritt 5 — Aufrufer anpassen - `GameScene.update()`: `stateManager.advanceTime(delta)` aufrufen, dann Tick-Methoden ohne `delta` - `FarmingSystem`: Watering-Logik auf neue `growsAt`-Berechnung umstellen ### Schritt 6 — Save/Load - `StateManager.load()`: nach dem Laden `rebuildQueues()` aufrufen - Migrations-Check für alte `stageTimerMs`-Felder --- ## Was wir NICHT ändern - Die Persistierungslogik bleibt im Wesentlichen gleich — `growsAt` ist ein einfacher `number` wie `stageTimerMs` - Kein Breaking-Change an der Adapter-API - Kein neues Abhängigkeit --- ## Offene Fragen für die Diskussion 1. Soll `gameTime` über Saves hinweg akkumulieren (d.h. persistiert werden), oder bei jedem Spielstart bei 0 anfangen und `growsAt` relativ zu „Ladezeit + Rest" initialisieren? 2. Soll `tileRecovery` auch umgestellt werden, oder erstmal nur Crops & Seedlings (da die häufiger/zahlreicher sind)? 3. Soll der Watering-Sonderfall direkt mitgemacht werden, oder erst in einem Follow-up?
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: tekki/nissefolk#36
No description provided.