fix(ui+game): responsive profile bar, auto-wave toggle, and stats on early exit

Profile bar on phones:
- With the new Statistik/Admin chips the bar overflowed the viewport on
  small screens. It now wraps (flex-wrap + max-width) and compacts its
  paddings, fonts and chip sizes below 560px

Auto-wave (🔁 button in the HUD controls):
- New persistent toggle (localStorage) that starts the next wave on its
  own ~0.4s after intermission begins, collecting the full early-call
  bonus without pressing the wave button every round
- Toggling it on during an active intermission starts the wave
  immediately; works in solo and multiplayer (uses the same action path
  as the manual button); active state shows green

Stats are now saved when leaving via the menu:
- Previously only victory/defeat recorded a result — exiting a running
  game via "🚪 Zurück zum Hauptmenü" or the pause menu discarded the
  whole run, which is why endless-mode progress beyond wave 20 never
  showed up in the stats
- engine.abandon() records best score, stats and crystals (counted as a
  defeat) whenever a solo game is left mid-run; no-ops when no game is
  running or the result was already reported, so no double counting

Verified in the browser (390x844, admin account): profile bar wraps
compactly; 🔁 auto-started wave 1 during intermission without manual
click; exiting mid-wave recorded the run (💎 2, 1 Runde, beste Welle 1).
Build clean, 54/54 tests green.
This commit is contained in:
Tronax 2026-08-17 15:51:23 +02:00
parent 572f82a954
commit f7ab56e475
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
5 changed files with 89 additions and 1 deletions

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { store } from '@/game/store' import { store } from '@/game/store'
import { engine } from '@/game/engine' import { engine } from '@/game/engine'
import { mpgame, uiSetSpeed, uiStartWaveEarly, uiTogglePause } from '@/game/mpgame' import { mpgame, uiSetSpeed, uiStartWaveEarly, uiTogglePause } from '@/game/mpgame'
@ -18,11 +18,44 @@ function previewTooltip(kind: string): string {
return ENEMIES[kind as keyof typeof ENEMIES].name return ENEMIES[kind as keyof typeof ENEMIES].name
} }
// ---------------------------------------------------------------- auto wave
// When enabled, the next wave starts on its own the moment intermission
// begins (collecting the full early-call bonus) no button pressing.
let autoTimer: ReturnType<typeof setTimeout> | null = null
function scheduleAutoWave(): void {
if (autoTimer) clearTimeout(autoTimer)
autoTimer = setTimeout(() => {
autoTimer = null
if (store.autoWave && store.phase === 'intermission') uiStartWaveEarly()
}, 400)
}
function toggleAutoWave(): void {
store.autoWave = !store.autoWave
if (typeof localStorage !== 'undefined') {
localStorage.setItem('trxtd-autowave', store.autoWave ? '1' : '0')
}
if (store.autoWave && store.phase === 'intermission') scheduleAutoWave()
}
watch(
() => store.phase,
(p) => {
if (p === 'intermission' && store.autoWave) scheduleAutoWave()
},
)
onBeforeUnmount(() => {
if (autoTimer) clearTimeout(autoTimer)
})
function exitToMenu(): void { function exitToMenu(): void {
confirmExit.value = false confirmExit.value = false
if (store.mp.active) { if (store.mp.active) {
mpgame.leave() mpgame.leave()
} else { } else {
engine.abandon() // record stats/crystals so the running round is not lost
store.screen = 'menu' store.screen = 'menu'
store.result = null store.result = null
engine.setPaused(false) engine.setPaused(false)
@ -72,6 +105,14 @@ function exitToMenu(): void {
</div> </div>
<div class="hud-controls"> <div class="hud-controls">
<button
class="btn icon auto"
:class="{ active: store.autoWave }"
:title="store.autoWave ? 'Auto-Welle: nächste Welle startet automatisch' : 'Nächste Welle automatisch starten'"
@click="toggleAutoWave()"
>
🔁
</button>
<button <button
v-if="!store.mp.active" v-if="!store.mp.active"
class="btn icon" class="btn icon"
@ -210,6 +251,10 @@ function exitToMenu(): void {
background: var(--accent); background: var(--accent);
color: #10151c; color: #10151c;
} }
.icon.auto.active {
background: #59b34d;
color: #fff;
}
.icon.danger:hover { .icon.danger:hover {
background: #a33; background: #a33;
color: #fff; color: #fff;

View file

@ -4,6 +4,7 @@ import { engine } from '@/game/engine'
function menu(): void { function menu(): void {
engine.setPaused(false) engine.setPaused(false)
engine.abandon() // record stats/crystals so the running round is not lost
store.screen = 'menu' store.screen = 'menu'
} }
</script> </script>

View file

@ -49,7 +49,9 @@ async function doLogout(): Promise<void> {
.profile-bar { .profile-bar {
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
max-width: 100%;
background: var(--panel); background: var(--panel);
border: 1px solid var(--panel-border); border: 1px solid var(--panel-border);
border-radius: 12px; border-radius: 12px;
@ -79,6 +81,25 @@ async function doLogout(): Promise<void> {
padding: 5px 10px; padding: 5px 10px;
cursor: pointer; cursor: pointer;
} }
/* Phones: chips wrap into compact rows instead of overflowing */
@media (max-width: 560px) {
.profile-bar {
gap: 5px;
padding: 5px 8px;
}
.crystals {
font-size: 13px;
}
.name {
font-size: 11.5px;
max-width: 92px;
}
.chip {
font-size: 11px;
padding: 4px 8px;
}
}
.chip:hover { .chip:hover {
border-color: var(--accent); border-color: var(--accent);
} }

View file

@ -575,6 +575,24 @@ export class GameEngine {
this.onGameEnd?.({ win, score: this.score, wave: this.waveNo, kills: this.kills, crystalsEarned }) this.onGameEnd?.({ win, score: this.score, wave: this.waveNo, kills: this.kills, crystalsEarned })
} }
/**
* Solo: leaving a running game via "zum Hauptmenü"/Pause-Menü. Records the
* run (best score, stats, crystals counted as a defeat) so progress from
* endless runs or early exits is not lost. No-ops when no game is running
* or the result was already reported by finish().
*/
abandon(): void {
if (this.mpGameActive) return
if (this.phase !== 'wave' && this.phase !== 'intermission') return
const bestBefore = loadBest(this.difficulty, this.mapId)
const best = Math.max(bestBefore, this.score)
if (typeof localStorage !== 'undefined') {
localStorage.setItem(bestScoreKey(this.difficulty, this.mapId), String(best))
}
const crystalsEarned = calcCrystalsEarned(this.waveNo, this.score, false)
this.onGameEnd?.({ win: false, score: this.score, wave: this.waveNo, kills: this.kills, crystalsEarned })
}
private spawnEnemy(kind: EnemyKind): void { private spawnEnemy(kind: EnemyKind): void {
const def = ENEMIES[kind] const def = ENEMIES[kind]
const diff = DIFFICULTIES[this.difficulty] const diff = DIFFICULTIES[this.difficulty]

View file

@ -41,6 +41,9 @@ export const store = reactive({
buildType: null as TowerKind | null, buildType: null as TowerKind | null,
hoverValid: false, hoverValid: false,
/** automatically start the next wave as soon as intermission begins */
autoWave: typeof localStorage !== 'undefined' && localStorage.getItem('trxtd-autowave') === '1',
selected: null as SelectedTowerInfo | null, selected: null as SelectedTowerInfo | null,
obstacle: null as { tx: number; ty: number; type: 'tree' | 'rock'; cost: number } | null, obstacle: null as { tx: number; ty: number; type: 'tree' | 'rock'; cost: number } | null,