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:
parent
572f82a954
commit
f7ab56e475
5 changed files with 89 additions and 1 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue