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:
parent
4ec0e483aa
commit
ef75203db7
12 changed files with 130 additions and 15 deletions
46
src/App.vue
46
src/App.vue
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue