feat(duel): allow swapping main view to opponent's board

- Add a toggle on the picture-in-picture window to swap the big board
  with the mini view of the opponent's field
- Color-coded frame and badge identify whose board is currently shown
  (blue = player 1, orange = player 2)
- Block building and rushing while spectating the opponent's board;
  the PiP then shows your own field instead
- Clarify duel-mode descriptions in lobby and start screen, add hints
  in the multiplayer bar
- Replace previous announcement banners so only one shows at a time
This commit is contained in:
Tronax 2026-08-16 13:35:46 +02:00
parent 4ec0e483aa
commit ef75203db7
Signed by: Tronax
SSH key fingerprint: SHA256:2pKKXDZucWvaF/GzXNz0FY53EAO1YDLN80bqS+TTz/o
12 changed files with 130 additions and 15 deletions

View file

@ -10,7 +10,28 @@ import PiPCanvas from '@/components/PiPCanvas.vue'
import StartScreen from '@/components/StartScreen.vue'
import TowerPanel from '@/components/TowerPanel.vue'
import TowerShop from '@/components/TowerShop.vue'
import { playerColor } from '@/game/config'
import { store } from '@/game/store'
import { computed } from 'vue'
/** duel: colored frame + label around the main canvas showing whose board it is */
const frameStyle = computed(() => {
if (!store.mp.active || store.mp.mode !== 'duel') return undefined
const viewedOwner = store.mp.viewOpponent ? 1 - store.mp.myId : store.mp.myId
return { boxShadow: `0 0 0 4px ${playerColor(viewedOwner)}` }
})
const badgeText = computed(() => {
if (!store.mp.active || store.mp.mode !== 'duel') return ''
return store.mp.viewOpponent
? `👁 ${store.mp.peerName} Ansicht (zum Bauen zurückwechseln)`
: '🛡 Deine Basis'
})
const badgeColor = computed(() => {
const viewedOwner = store.mp.viewOpponent ? 1 - store.mp.myId : store.mp.myId
return playerColor(viewedOwner)
})
</script>
<template>
@ -21,8 +42,15 @@ import { store } from '@/game/store'
<Hud />
<MPBar />
<div class="canvas-wrap">
<div class="canvas-box">
<div class="canvas-box" :style="frameStyle">
<GameCanvas />
<div
v-if="badgeText"
class="board-badge"
:style="{ background: badgeColor }"
>
{{ badgeText }}
</div>
<TowerPanel />
<ObstaclePanel />
<PiPCanvas />
@ -56,5 +84,21 @@ import { store } from '@/game/store'
}
.canvas-box {
position: relative;
border-radius: 10px;
}
.board-badge {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
z-index: 4;
color: #10151c;
font-size: 11.5px;
font-weight: 800;
padding: 3px 12px;
border-radius: 20px;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
white-space: nowrap;
}
</style>

View file

@ -43,6 +43,8 @@ function onMouseMove(e: MouseEvent): void {
function handleGameClick(x: number, y: number): void {
engine.setHover(x, y)
if (mpgame.active) {
// while spectating the opponent's board you cannot build
if (store.mp.viewOpponent) return
const tx = Math.floor(x / TILE)
const ty = Math.floor(y / TILE)
if (engine.buildType) {

View file

@ -5,8 +5,8 @@ import { mpgame } from '@/game/mpgame'
const modeText = computed(() =>
store.mp.mode === 'coop'
? { icon: '🤝', title: 'Coop', desc: 'Gemeinsame Basis, gemeinsames Gold gemeinsam gegen 20 Wellen.' }
: { icon: '⚔', title: '1v1', desc: 'Jeder verteidigt seine eigene Basis. Rushes schicken Gegner zum Feind.' },
? { icon: '🤝', title: 'Coop', desc: 'Ein gemeinsames Spielfeld: gemeinsames Gold und Leben, 20 Wellen zusammen.' }
: { icon: '⚔', title: '1v1', desc: 'Jeder verteidigt sein eigenes Spielfeld (deins ist das große). Den Gegner siehst du live im Mini-Fenster und kannst ihm Rushes schicken!' },
)
function leave(): void {

View file

@ -22,10 +22,11 @@ function rush(): void {
<b>{{ store.mp.peerName }}</b>
{{ store.mp.opponentLives }} · 🪙 {{ store.mp.opponentGold }} · 🌊 {{ store.mp.opponentWave }}
</span>
<span class="hint">Großes Feld = deins · Gegner live oben links ( zum Wechseln)</span>
<button
class="btn rush"
:disabled="store.money < store.mp.rushCost"
:title="'Schickt Läufer-Rush auf die Basis des Gegners'"
:disabled="store.money < store.mp.rushCost || store.mp.viewOpponent"
:title="'Schickt Läufer-Rush auf das Feld des Gegners'"
@click="rush"
>
Rush senden · 🪙 {{ store.mp.rushCost }}
@ -64,6 +65,10 @@ function rush(): void {
.opp b {
color: var(--text);
}
.hint {
color: var(--text-dim);
font-size: 12px;
}
.btn.rush {
margin-left: auto;
background: linear-gradient(180deg, #e0563f, #b93a28);

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { store } from '@/game/store'
import { engine } from '@/game/engine'
import { mpgame } from '@/game/mpgame'
import { H, W } from '@/game/config'
import { Renderer } from '@/game/render'
@ -16,8 +17,9 @@ onMounted(() => {
renderer.setCssSize(W * 0.32, H * 0.32)
const loop = (): void => {
if (store.mp.mode === 'duel' && store.screen === 'game') {
const remote = mpgame.remoteEngine
if (remote) renderer?.render(remote)
// while viewing the opponent's board big, the PiP shows your own field
const target = store.mp.viewOpponent ? engine : mpgame.remoteEngine
if (target) renderer?.render(target)
}
raf = requestAnimationFrame(loop)
}
@ -29,11 +31,18 @@ onBeforeUnmount(() => {
renderer?.destroy()
renderer = null
})
function toggle(): void {
mpgame.toggleView()
}
</script>
<template>
<div v-if="store.mp.mode === 'duel'" class="pip">
<div class="pip-label">👁 {{ store.mp.peerName }}</div>
<button class="pip-label" title="Ansicht wechseln: großes Spielfeld ↔ Mini-Fenster" @click="toggle">
<template v-if="store.mp.viewOpponent">🛡 Dein Feld </template>
<template v-else>👁 {{ store.mp.peerName }} </template>
</button>
<canvas ref="el" class="pip-canvas" />
</div>
</template>
@ -55,8 +64,13 @@ onBeforeUnmount(() => {
color: var(--text);
font-size: 11px;
font-weight: 700;
padding: 2px 8px;
padding: 3px 8px;
align-self: flex-start;
cursor: pointer;
font-family: inherit;
}
.pip-label:hover {
border-color: var(--accent);
}
.pip-canvas {
display: block;

View file

@ -71,7 +71,13 @@ async function joinRoom(): Promise<void> {
</div>
<div class="mp-row">
<button class="mp-btn coop" @click="createRoom('coop')">🤝 Coop-Raum erstellen</button>
<button class="mp-btn duel" @click="createRoom('duel')"> 1v1-Raum erstellen</button>
<button
class="mp-btn duel"
title="Jeder verteidigt sein EIGENES Spielfeld (das große). Das Feld des Gegners siehst du live im Mini-Fenster und kannst ihm Rushes schicken!"
@click="createRoom('duel')"
>
1v1-Raum erstellen
</button>
</div>
<div class="mp-row">
<input

View file

@ -15,6 +15,13 @@ export const OBSTACLE_COST: Record<'tree' | 'rock', number> = {
rock: 40,
}
/** multiplayer player identity colors (blue = player 1, orange = player 2) */
export const PLAYER_COLORS: [string, string] = ['#4da3ff', '#ffb14d']
export function playerColor(playerId: number | null): string {
return playerId === null || playerId === 0 ? PLAYER_COLORS[0] : PLAYER_COLORS[1]
}
/** ground path in tile coords; -1/20 are outside the grid (spawn/exit) */
export const MAP_WAYPOINTS: [number, number][] = [
[-1, 5],

View file

@ -41,6 +41,8 @@ import { Renderer } from './render'
/** implemented by the multiplayer controller (mpgame.ts) */
export interface MPController {
frame(dtReal: number): void
/** engine whose board is shown on the main canvas (view swap in duel mode) */
mainView(): GameEngine
}
interface SpawnEntry {
@ -357,7 +359,7 @@ export class GameEngine {
// multiplayer lockstep: the controller advances both engines in sync ticks
this.mpController.frame(dtReal)
this.syncStore()
this.renderer?.render(this)
this.renderer?.render(this.mpController.mainView())
return
}
const active = store.screen === 'game' && !this.paused
@ -1041,6 +1043,8 @@ export class GameEngine {
}
announce(str: string, sub: string): void {
// only one banner at a time replaces any previous announcement
this.effects = this.effects.filter((fx) => fx.type !== 'announce')
this.fx({ type: 'announce', str, sub, life: 1.8, max: 1.8 })
}

View file

@ -172,7 +172,11 @@ class MpGameController {
remote.mpMode = 'duel'
remote.localPlayerId = 1 - info.you
remote.ghost = true
// CRITICAL: index boards by PLAYER ID so engines[from] is always the
// board owned by the acting player on every client
this.engines = [engine, remote]
this.engines[info.you] = engine
this.engines[1 - info.you] = remote
this.remoteEngine = remote
}
@ -190,13 +194,33 @@ class MpGameController {
this.net.remoteTick = -1
if (this.heartbeat) clearInterval(this.heartbeat)
this.heartbeat = setInterval(() => this.net.sendProgress(this.myTick), 100)
store.mp.viewOpponent = false
engine.announce(
info.mode === 'coop' ? 'Coop-Modus!' : '1v1-Duell!',
info.mode === 'coop' ? 'Gemeinsames Gold Teamwork!' : 'Verteidige deine Basis!',
info.mode === 'coop'
? 'Gemeinsames Gold Teamwork!'
: 'Dies ist DEIN Feld Gegner live im Mini-Fenster',
)
}
/** duel: is the opponent's board currently shown on the main canvas? */
get viewSwap(): boolean {
return store.mp.viewOpponent && this.mode === 'duel' && this.remoteEngine !== null
}
/** engine whose board is rendered on the big canvas */
mainView(): GameEngine {
return this.viewSwap && this.remoteEngine ? this.remoteEngine : engine
}
toggleView(): void {
if (this.mode === 'duel' && this.remoteEngine) {
store.mp.viewOpponent = !store.mp.viewOpponent
engine.cancelMode()
}
}
private disposeGame(): void {
if (this.heartbeat) clearInterval(this.heartbeat)
this.heartbeat = null

View file

@ -1,4 +1,4 @@
import { ENEMIES, H, TILE, TOWERS, W } from './config'
import { ENEMIES, H, TILE, TOWERS, W, playerColor } from './config'
import type { GameEngine } from './engine'
import type { Enemy, Tower } from './types'
import { mulberry32 } from './utils'
@ -342,7 +342,7 @@ export class Renderer {
g.lineTo(x + 10, y + 26)
g.closePath()
g.fill()
// banner
// banner carries the owner's color in duel mode (blue/orange)
g.strokeStyle = '#5b4a35'
g.lineWidth = 3
g.beginPath()
@ -350,7 +350,7 @@ export class Renderer {
g.lineTo(x, y - 62)
g.stroke()
const wave = Math.sin(eng.time * 4) * 3
g.fillStyle = '#d84b3f'
g.fillStyle = eng.mpMode === 'duel' ? playerColor(eng.localPlayerId) : '#d84b3f'
g.beginPath()
g.moveTo(x + 1, y - 62)
g.lineTo(x + 22 + wave, y - 56)

View file

@ -54,6 +54,8 @@ export const store = reactive({
opponentWave: 0,
rushCost: 0,
resultMsg: '',
/** duel: show the opponent's board on the main canvas */
viewOpponent: false,
},
})

View file

@ -1,5 +1,12 @@
import { createApp } from 'vue'
import App from './App.vue'
import { store } from '@/game/store'
import { mpgame } from '@/game/mpgame'
import './style.css'
// diagnostic hook (read-only debugging from devtools/automation)
if (typeof window !== 'undefined') {
;(window as unknown as Record<string, unknown>).__trxtd = { store, mpgame }
}
createApp(App).mount('#app')