trigo / trigo-web /app /src /views /TrigoView.vue
k-l-lambda's picture
Update trigo-web with VS People multiplayer mode
15f353f
<template>
<div class="trigo-view">
<div class="view-header">
<!-- Single Game Mode: Current Player & Move Count -->
<div v-if="gameMode === 'single'" class="view-status">
<span
class="turn-indicator"
:class="{ black: currentPlayer === 'black', white: currentPlayer === 'white' }"
>
{{ currentPlayer === "black" ? "Black" : "White" }}'s Turn
</span>
<span class="move-count">Move: {{ moveCount }}</span>
</div>
<!-- VS AI Mode: Player Info & AI Status -->
<div v-else-if="gameMode === 'vs-ai'" class="view-status ai-mode">
<div class="player-info" :class="{ 'on-turn': currentPlayer === humanPlayerColor }">
<span class="player-label">You:</span>
<span
class="stone-icon"
:class="humanPlayerColor === 'black' ? 'black' : 'white'"
></span>
</div>
<button class="btn-swap-colors" @click="swapColors">⇄ Swap</button>
<div class="ai-status" :class="{ 'on-turn': currentPlayer === aiPlayerColor && !aiThinking }">
<span class="ai-indicator">🤖 AI:</span>
<span v-if="!aiReady" class="ai-level">Loading...</span>
<span v-else-if="aiThinking" class="ai-level ai-thinking">Thinking...</span>
<span v-else-if="aiError" class="ai-level ai-error">Error</span>
<span
v-else
class="stone-icon"
:class="aiPlayerColor === 'black' ? 'black' : 'white'"
></span>
</div>
<span class="move-count">Move: {{ moveCount }}</span>
<span v-if="lastMoveTime > 0" class="ai-time">({{ lastMoveTime.toFixed(0) }}ms)</span>
</div>
<!-- VS People Mode: Room & Players -->
<div v-else-if="gameMode === 'vs-people'" class="view-status people-mode">
<div class="room-info">
<span class="room-label">Room:</span>
<span class="room-code">{{ playerStore.roomId || "---" }}</span>
<button
v-if="playerStore.roomId"
class="btn-copy-room"
@click="copyRoomCode"
title="Copy room code"
>
📋
</button>
</div>
<div class="players-info">
<div class="player-display" :class="{ 'on-turn': isLocalPlayerTurn }">
<inline-nickname-editor
:nickname="playerStore.nickname"
:editable="true"
:player-color="playerStore.playerColor"
@update="updateLocalNickname"
/>
</div>
<span class="vs-divider">vs</span>
<div class="player-display" :class="{ 'on-turn': !isLocalPlayerTurn }">
<inline-nickname-editor
v-if="playerStore.opponentNickname"
:nickname="playerStore.opponentNickname"
:editable="false"
:player-color="opponentPlayerColor"
/>
<span v-else class="waiting-text">Waiting...</span>
</div>
</div>
<span class="connection-status" :class="connectionStatusClass">
{{ connectionStatusIcon }} {{ connectionStatusText }}
</span>
</div>
<!-- Library Mode: Game Info & Controls -->
<div v-else-if="gameMode === 'library'" class="view-status library-mode">
<div class="game-info">
<span class="game-title">Game #12345</span>
<span class="game-date">2025-01-18</span>
</div>
<div class="library-actions">
<button class="btn-library btn-load" title="Load Game">📂 Load</button>
<button class="btn-library btn-export" title="Export TGN">📤 Export</button>
</div>
</div>
<!-- Header Controls (right side) -->
<div class="header-controls" v-if="gameMode === 'single'">
<select v-model="selectedBoardShape" class="board-shape-select">
<option value="3x3x3">3×3×3</option>
<option value="5x5x5">5×5×5</option>
<option value="7x7x7">7×7×7</option>
<option value="9x9x1">9×9×1 (2D)</option>
<option value="9x9x9">9×9×9</option>
<option value="5x5x1">5×5×1 (2D)</option>
<option value="7x7x1">7×7×1 (2D)</option>
</select>
<button class="btn-reset" @click="newGame">
{{ gameStarted ? "Reset" : "Start" }}
</button>
</div>
<div class="header-controls" v-else-if="gameMode === 'vs-ai'">
<select v-model="selectedBoardShape" class="board-shape-select">
<option value="3x3x3">3×3×3</option>
<option value="5x5x5">5×5×5</option>
<option value="7x7x7">7×7×7</option>
<option value="9x9x1">9×9×1 (2D)</option>
<option value="9x9x9">9×9×9</option>
<option value="5x5x1">5×5×1 (2D)</option>
<option value="7x7x1">7×7×1 (2D)</option>
</select>
<button class="btn-reset" @click="newGame">
{{ gameStarted ? "Reset" : "Start" }}
</button>
</div>
<div class="header-controls" v-else-if="gameMode === 'vs-people'">
<select
v-model="preferredColor"
class="color-preference-select"
:disabled="gameStarted || connectionStatus === 'in-room'"
>
<option value="black">Play as Black</option>
<option value="white">Play as White</option>
</select>
<select v-model="selectedBoardShape" class="board-shape-select">
<option value="3x3x3">3×3×3</option>
<option value="5x5x5">5×5×5</option>
<option value="7x7x7">7×7×7</option>
<option value="9x9x1">9×9×1 (2D)</option>
<option value="9x9x9">9×9×9</option>
<option value="5x5x1">5×5×1 (2D)</option>
<option value="7x7x1">7×7×1 (2D)</option>
</select>
<button class="btn-reset" @click="resetMultiplayerGame">Reset</button>
</div>
</div>
<div class="view-body">
<!-- 3D Board Canvas Area (Left) -->
<div class="board-container">
<div class="viewport-wrapper">
<canvas ref="viewportCanvas" class="viewport-canvas"></canvas>
<div class="viewport-overlay" v-if="isLoading">
<p class="loading-text">Loading 3D Board...</p>
</div>
<!-- Inspect Mode Tooltip -->
<div class="inspect-tooltip" v-if="inspectInfo.visible">
<div class="tooltip-content">
<span class="tooltip-label"
>{{ inspectInfo.groupSize }} stone{{
inspectInfo.groupSize > 1 ? "s" : ""
}}</span
>
<span class="tooltip-divider">|</span>
<span class="tooltip-label"
>Liberties: {{ inspectInfo.liberties }}</span
>
</div>
</div>
</div>
</div>
<!-- Game Controls & Info Panel (Right) -->
<div class="controls-panel">
<!-- Score Display (Captured/Territory) -->
<div
class="panel-section score-section"
:class="{ 'show-territory': showTerritoryMode }"
>
<h3 class="section-title">
{{ showTerritoryMode ? "Territory" : "Captured" }}
</h3>
<div class="score-display">
<button class="score-button black" :disabled="!gameStarted">
<span class="color-indicator black-stone"></span>
<span class="score">{{
showTerritoryMode ? blackScore : capturedStones.black
}}</span>
</button>
<button class="score-button white" :disabled="!gameStarted">
<span class="color-indicator white-stone"></span>
<span class="score">{{
showTerritoryMode ? whiteScore : capturedStones.white
}}</span>
</button>
</div>
<button
class="compute-territory"
@click="computeTerritory"
:disabled="!gameStarted"
>
Compute Territory
</button>
</div>
<!-- Move History -->
<div class="panel-section routine-section">
<h3 class="section-title">
Move History
<button
class="btn-view-tgn"
@click="showTGNModal = true"
title="View TGN Notation"
>
TGN
</button>
</h3>
<div class="routine-content" ref="moveHistoryContainer">
<div class="move-list">
<!-- START placeholder -->
<div
:class="{ active: currentMoveIndex === 0 }"
@click="jumpToMove(0)"
class="move-row start-row"
>
<span class="round-number"></span>
<span class="move-label open-label">OPEN</span>
</div>
<!-- Move history items by round -->
<div
v-for="(round, roundIndex) in moveRounds"
:key="roundIndex"
class="move-row"
>
<span class="round-number">{{ roundIndex + 1 }}.</span>
<div
class="move-cell black-move"
:class="{ active: round.blackIndex === currentMoveIndex }"
@click="jumpToMove(round.blackIndex)"
>
<span class="stone-icon black"></span>
<span class="move-coords" v-if="!round.black.isPass">{{
formatMoveCoords(round.black)
}}</span>
<span class="move-label" v-else>pass</span>
</div>
<div
v-if="round.white"
class="move-cell white-move"
:class="{ active: round.whiteIndex === currentMoveIndex }"
@click="jumpToMove(round.whiteIndex)"
>
<span class="stone-icon white"></span>
<span class="move-coords" v-if="!round.white.isPass">{{
formatMoveCoords(round.white)
}}</span>
<span class="move-label" v-else>pass</span>
</div>
<div v-else class="move-cell empty"></div>
</div>
</div>
</div>
</div>
<!-- Game Controls -->
<div class="panel-section controls-section">
<h3 class="section-title">Controls</h3>
<div class="control-buttons">
<div class="play-controls">
<button class="btn btn-pass" @click="pass" :disabled="!gameStarted">
Pass
</button>
<button class="btn btn-resign" @click="resign" :disabled="!gameStarted">
Resign
</button>
</div>
<div class="history-controls">
<button
class="btn btn-icon btn-prev"
@click="previousMove"
:disabled="currentMoveIndex <= 0"
title="Previous Move"
>
</button>
<button
class="btn btn-icon btn-next"
@click="nextMove"
:disabled="currentMoveIndex >= moveHistory.length"
title="Next Move"
>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- TGN Modal -->
<div class="tgn-modal" v-if="showTGNModal" @click.self="showTGNModal = false">
<div class="tgn-modal-content">
<div class="tgn-modal-header">
<h3>Game Notation (TGN)</h3>
<div class="tgn-status" :class="tgnValidationClass">
{{ tgnValidationMessage }}
</div>
<button class="btn-close" @click="showTGNModal = false">×</button>
</div>
<div class="tgn-modal-body">
<textarea
v-model="editableTGNContent"
:class="['tgn-textarea', tgnValidationClass]"
@input="onTGNEdit"
placeholder="Enter TGN game notation..."
></textarea>
</div>
<div class="tgn-modal-footer">
<button class="btn btn-apply" @click="applyTGN" :disabled="!tgnIsValid">
Apply TGN
</button>
<button class="btn btn-copy" @click="copyTGN">Copy to Clipboard</button>
<button class="btn btn-close-modal" @click="showTGNModal = false">Close</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from "vue";
import { useRoute } from "vue-router";
import { TrigoViewport } from "@/services/trigoViewport";
import { useGameStore } from "@/stores/gameStore";
import { usePlayerStore } from "@/stores/playerStore";
import { useTrigoAgent } from "@/composables/useTrigoAgent";
import { useSocket } from "@/composables/useSocket";
import { useRoomHash } from "@/composables/useRoomHash";
import InlineNicknameEditor from "@/components/InlineNicknameEditor.vue";
import { storeToRefs } from "pinia";
import type { BoardShape } from "../../../inc/trigo";
import { Stone, validateMove, StoneType, validateTGN } from "../../../inc/trigo";
import { TrigoGameFrontend } from "@/utils/TrigoGameFrontend";
import { encodeAb0yz } from "../../../inc/trigo/ab0yz";
import { storage, StorageKey } from "@/utils/storage";
// Get current route to determine game mode
const route = useRoute();
const gameMode = computed(() => (route.meta.mode as string) || "single");
// Game store
const gameStore = useGameStore();
const {
game,
currentPlayer,
moveHistory,
currentMoveIndex,
capturedStones,
gameStatus,
boardShape,
moveCount,
isGameActive,
passCount
} = storeToRefs(gameStore);
// Player store (for multiplayer)
const playerStore = usePlayerStore();
// Socket.io (for multiplayer)
const socketApi = useSocket();
// Room hash management (for VS People mode)
const { getRoomIdFromHash, updateHash, clearHash, isValidRoomId } = useRoomHash();
const isJoiningRoom = ref(false);
// Helper functions for board shape parsing
const parseBoardShape = (shapeStr: string): BoardShape => {
const parts = shapeStr
.split(/[^\d]+/)
.filter(Boolean)
.map(Number);
return { x: parts[0] || 5, y: parts[1] || 5, z: parts[2] || 5 };
};
const printBoardShape = (shape: BoardShape): string => {
return `${shape.x}*${shape.y}*${shape.z}`;
};
// Format move coordinates using TGN notation
const formatMoveCoords = (move: { x: number; y: number; z: number }): string => {
const shape = boardShape.value;
return encodeAb0yz([move.x, move.y, move.z], [shape.x, shape.y, shape.z]);
};
// AI Agent (for VS AI mode)
const aiAgent = useTrigoAgent();
const { isReady: aiReady, isThinking: aiThinking, error: aiError, lastMoveTime } = aiAgent;
// AI player color with session storage persistence
const loadAIColor = (): "black" | "white" => {
const stored = storage.getString(StorageKey.AI_PLAYER_COLOR);
return stored === "black" || stored === "white" ? stored : "white";
};
const aiPlayerColor = ref<"black" | "white">(loadAIColor());
const humanPlayerColor = computed(() => (aiPlayerColor.value === "white" ? "black" : "white"));
// VS People color preference with storage persistence
const loadPreferredColor = (): "black" | "white" => {
const stored = storage.getString("vs-people-color-preference");
return stored === "black" || stored === "white" ? stored : "black";
};
const preferredColor = ref<"black" | "white">(loadPreferredColor());
// Watch for color preference changes and persist to storage
watch(preferredColor, (newColor) => {
storage.setString("vs-people-color-preference", newColor);
});
// Local state
const hoveredPosition = ref<string | null>(null);
const blackScore = ref(0);
const whiteScore = ref(0);
const isLoading = ref(true);
const selectedBoardShape = ref<string>(printBoardShape(boardShape.value));
const showTerritoryMode = ref(false);
const showTGNModal = ref(false);
const inspectInfo = ref({
visible: false,
groupSize: 0,
liberties: 0
});
// TGN Editor state
const editableTGNContent = ref<string>("");
const tgnValidationState = ref<"idle" | "valid" | "invalid">("idle");
const tgnValidationError = ref<string>("");
let tgnValidationTimeout: ReturnType<typeof setTimeout> | null = null;
// Canvas reference and viewport
const viewportCanvas = ref<HTMLCanvasElement | null>(null);
const moveHistoryContainer = ref<HTMLDivElement | null>(null);
let viewport: TrigoViewport | null = null;
let resizeObserver: ResizeObserver | null = null;
// Computed properties
const gameStarted = computed(() => isGameActive.value);
// Group moves into rounds (pairs of black and white moves)
const moveRounds = computed(() => {
const rounds: Array<{
black: any;
white: any | null;
blackIndex: number;
whiteIndex: number | null;
}> = [];
for (let i = 0; i < moveHistory.value.length; i += 2) {
const blackMove = moveHistory.value[i];
const whiteMove = moveHistory.value[i + 1] || null;
rounds.push({
black: blackMove,
white: whiteMove,
blackIndex: i + 1, // Convert array index to "moves applied" count
whiteIndex: whiteMove ? i + 2 : null // Convert array index to "moves applied" count
});
}
return rounds;
});
// Check if selected board shape differs from current game board shape
const isBoardShapeDirty = computed(() => {
const selectedShape = parseBoardShape(selectedBoardShape.value);
const currentShape = boardShape.value;
return (
selectedShape.x !== currentShape.x ||
selectedShape.y !== currentShape.y ||
selectedShape.z !== currentShape.z
);
});
// Generate TGN content
const tgnContent = computed(() => {
return (
gameStore.game?.toTGN({
application: "Trigo Demo v1.0",
date: new Date().toISOString().split("T")[0].replace(/-/g, ".")
}) || ""
);
});
// TGN validation computed properties
const tgnIsValid = computed(() => tgnValidationState.value === "valid");
const tgnValidationClass = computed(() => {
if (tgnValidationState.value === "idle") return "idle";
if (tgnValidationState.value === "valid") return "valid";
return "invalid";
});
const tgnValidationMessage = computed(() => {
if (tgnValidationState.value === "idle") return "Ready to validate";
if (tgnValidationState.value === "valid") return "✓ Valid TGN";
return `✗ ${tgnValidationError.value}`;
});
// Debounced TGN validation (synchronous)
const onTGNEdit = () => {
// Clear existing timeout
if (tgnValidationTimeout) {
clearTimeout(tgnValidationTimeout);
}
// Set validation state to idle while waiting for debounce
tgnValidationState.value = "idle";
// Set new debounce timeout (300ms)
tgnValidationTimeout = setTimeout(() => {
const result = validateTGN(editableTGNContent.value);
if (result.valid) {
tgnValidationState.value = "valid";
tgnValidationError.value = "";
} else {
tgnValidationState.value = "invalid";
tgnValidationError.value = result.error || "Invalid TGN format";
}
}, 300);
};
// Apply TGN and update game state (synchronous)
const applyTGN = () => {
if (!tgnIsValid.value) return;
try {
const newGame = TrigoGameFrontend.fromTGN(editableTGNContent.value);
// Update game store with the new TrigoGameFrontend instance
gameStore.game = newGame;
// Save to session storage
gameStore.saveToSessionStorage();
// Update viewport with new board state
// The getters will automatically compute the new values from gameStore.game
if (viewport) {
viewport.setBoardShape(newGame.getShape());
syncViewportWithStore();
}
// Close modal
showTGNModal.value = false;
} catch (err) {
console.error("Failed to apply TGN:", err);
tgnValidationState.value = "invalid";
tgnValidationError.value = err instanceof Error ? err.message : "Failed to apply TGN";
}
};
// Generate and apply AI move
const generateAIMove = async () => {
if (!aiReady.value || aiThinking.value || !gameStarted.value) {
return;
}
try {
console.log("[TrigoView] Generating AI move...");
// Get AI move - pass the game instance directly from store
const aiMove = await aiAgent.generateMove(gameStore.game);
// Apply AI move if valid
if (aiMove) {
// Check if AI chose to pass
if (aiMove.isPass) {
console.log("[TrigoView] AI chose to pass");
const result = gameStore.pass();
if (result.success) {
console.log(`[TrigoView] AI pass applied successfully (${lastMoveTime.value.toFixed(0)}ms)`);
} else {
console.error("[TrigoView] Failed to apply AI pass");
}
} else {
console.log(`[TrigoView] AI suggests move at (${aiMove.x}, ${aiMove.y}, ${aiMove.z})`);
// Make move in store
const result = gameStore.makeMove(aiMove.x, aiMove.y, aiMove.z);
if (result.success && viewport) {
// Add AI stone to viewport
const stoneColor = gameStore.opponentPlayer;
viewport.addStone(aiMove.x, aiMove.y, aiMove.z, stoneColor);
// Remove captured stones
if (result.capturedPositions && result.capturedPositions.length > 0) {
result.capturedPositions.forEach((pos: any) => {
viewport.removeStone(pos.x, pos.y, pos.z);
});
console.log(`[TrigoView] AI captured ${result.capturedPositions.length} stone(s)`);
}
console.log(`[TrigoView] AI move applied successfully (${lastMoveTime.value.toFixed(0)}ms)`);
}
}
} else {
console.log("[TrigoView] AI returned no move");
}
} catch (err) {
console.error("[TrigoView] Failed to generate AI move:", err);
}
};
// Sync viewport with current game state (for undo/redo/reset)
const syncViewportWithGame = () => {
if (!viewport) return;
// Get all stones from game store and add to viewport
const stones = gameStore.getAllStones();
for (const stone of stones) {
viewport.addStone(stone.x, stone.y, stone.z, stone.color);
}
console.log(`[TrigoView] Synced viewport with ${stones.length} stones`);
};
// Handle stone placement
const handleStoneClick = async (x: number, y: number, z: number) => {
if (!gameStarted.value) return;
// VS People mode: validate turn and emit socket event
if (gameMode.value === "vs-people") {
// Check if it's the local player's turn
if (!isLocalPlayerTurn.value) {
console.log("[TrigoView] Not your turn, ignoring click");
return;
}
// Emit move to server (server will validate and broadcast)
console.log(`[TrigoView] Emitting makeMove: (${x}, ${y}, ${z})`);
socketApi.makeMove(x, y, z);
return;
}
// Single player and VS AI mode: make move locally
const result = gameStore.makeMove(x, y, z);
if (result.success && viewport) {
// Add stone to viewport (store already switched player, so use opponent)
const stoneColor = gameStore.opponentPlayer;
viewport.addStone(x, y, z, stoneColor);
// Remove captured stones from viewport
if (result.capturedPositions && result.capturedPositions.length > 0) {
result.capturedPositions.forEach((pos) => {
viewport.removeStone(pos.x, pos.y, pos.z);
});
console.log(`Captured ${result.capturedPositions.length} stone(s)`);
}
// Hide domain visualization and switch back to captured display after move
viewport.hideDomainCubes();
showTerritoryMode.value = false;
// VS AI mode: Generate AI move after player move if it's AI's turn
if (gameMode.value === "vs-ai" && aiReady.value && currentPlayer.value === aiPlayerColor.value) {
setTimeout(() => {
generateAIMove();
}, 100);
}
}
};
// Handle position hover
const handlePositionHover = (x: number | null, y: number | null, z: number | null) => {
if (x !== null && y !== null && z !== null) {
hoveredPosition.value = `(${x}, ${y}, ${z})`;
} else {
hoveredPosition.value = null;
}
};
// Check if a position is droppable (validates with game rules)
const isPositionDroppable = (x: number, y: number, z: number): boolean => {
const pos = { x, y, z };
const playerColor = currentPlayer.value === "black" ? StoneType.BLACK : StoneType.WHITE;
const validation = validateMove(
pos,
playerColor,
gameStore.board,
boardShape.value,
gameStore.lastCapturedPositions
);
return validation.valid;
};
// Handle inspect mode callback
const handleInspectGroup = (groupSize: number, liberties: number) => {
if (groupSize > 0) {
inspectInfo.value = {
visible: true,
groupSize,
liberties
};
} else {
inspectInfo.value = {
visible: false,
groupSize: 0,
liberties: 0
};
}
};
// Swap AI player color
const swapColors = () => {
// Swap the color
aiPlayerColor.value = aiPlayerColor.value === "white" ? "black" : "white";
// Save to storage
storage.setString(StorageKey.AI_PLAYER_COLOR, aiPlayerColor.value);
console.log(`[TrigoView] AI color swapped to: ${aiPlayerColor.value}`);
// If game is started and it's now AI's turn, trigger AI move immediately
if (gameStarted.value && aiReady.value && currentPlayer.value === aiPlayerColor.value) {
setTimeout(() => {
generateAIMove();
}, 100);
}
};
// Game control methods
const newGame = () => {
// Parse selected board shape
const shape = parseBoardShape(selectedBoardShape.value);
// Initialize game in store
gameStore.initializeGame(shape);
gameStore.startGame();
// Update viewport with new board shape
if (viewport) {
viewport.setBoardShape(shape);
viewport.clearBoard();
viewport.setGameActive(true);
// Exit territory mode if active
viewport.hideDomainCubes();
}
// Reset scores and territory mode
blackScore.value = 0;
whiteScore.value = 0;
showTerritoryMode.value = false;
console.log(`Starting new game with board shape ${shape.x}×${shape.y}×${shape.z}`);
// VS AI mode: If AI plays black, generate first move
if (gameMode.value === "vs-ai" && aiPlayerColor.value === "black" && aiReady.value) {
setTimeout(() => {
generateAIMove();
}, 100);
}
};
// Reset game for multiplayer (VS People mode)
const resetMultiplayerGame = () => {
if (!confirm("Reset the game? This will clear all moves.")) return;
const shape = parseBoardShape(selectedBoardShape.value);
const swapColors = playerStore.playerColor !== preferredColor.value;
socketApi.socket.emit("resetGame", {
boardShape: shape,
swapColors: swapColors
}, (response: any) => {
if (response.success) {
console.log("Multiplayer game reset successfully");
// Local reset is handled by gameReset event listener
} else {
console.error("Reset failed:", response.error);
alert(`Failed to reset game: ${response.error || "Unknown error"}`);
}
});
};
const pass = () => {
// VS People mode: validate turn and emit socket event
if (gameMode.value === "vs-people") {
if (!isLocalPlayerTurn.value) {
console.log("[TrigoView] Not your turn, cannot pass");
return;
}
console.log("[TrigoView] Emitting pass");
socketApi.pass();
return;
}
// Single player and VS AI mode: pass locally
const previousPlayer = currentPlayer.value;
const success = gameStore.pass();
if (success) {
// Check if game ended due to double pass
//if (gameStore.gameResult?.reason === "double-pass") {
// showGameResult();
//} else {
console.log(`${previousPlayer} passed (Pass count: ${gameStore.passCount})`);
//}
}
};
const resign = () => {
// Confirm resignation
const confirmed = confirm(
`Are you sure ${currentPlayer.value} wants to resign?\n\nThis will end the game immediately.`
);
if (!confirmed) return;
//const success = gameStore.resign();
//if (success) {
// showGameResult();
//}
};
const showGameResult = () => {
const result = gameStore.gameResult;
if (!result) return;
// Don't set game as inactive - allow continued analysis
// if (viewport) {
// viewport.setGameActive(false);
// }
let message = "";
if (result.reason === "resignation") {
message = `${result.winner === "black" ? "Black" : "White"} wins by resignation!\n\nGame continues for analysis.`;
} else if (result.reason === "double-pass") {
// Calculate final scores for double pass
const territory = gameStore.computeTerritory();
const blackTotal = territory.black + capturedStones.value.black;
const whiteTotal = territory.white + capturedStones.value.white;
blackScore.value = blackTotal;
whiteScore.value = whiteTotal;
if (blackTotal > whiteTotal) {
message = `Black wins by ${blackTotal - whiteTotal} points!\n\nBlack: ${blackTotal} points\nWhite: ${whiteTotal} points\n\nGame continues for analysis.`;
} else if (whiteTotal > blackTotal) {
message = `White wins by ${whiteTotal - blackTotal} points!\n\nWhite: ${whiteTotal} points\nBlack: ${blackTotal} points\n\nGame continues for analysis.`;
} else {
message = `Game is a draw!\n\nBoth players: ${blackTotal} points\n\nGame continues for analysis.`;
}
}
setTimeout(() => {
alert(message);
}, 100);
};
const computeTerritory = () => {
if (!gameStarted.value) return;
// Toggle territory mode
if (showTerritoryMode.value) {
// Exit territory mode
if (viewport) {
viewport.hideDomainCubes();
}
showTerritoryMode.value = false;
// Reset scores to captured stones count
blackScore.value = 0;
whiteScore.value = 0;
return;
}
// Enter territory mode - Use store's territory calculation
const territory = gameStore.computeTerritory();
blackScore.value = territory.black + capturedStones.value.black;
whiteScore.value = territory.white + capturedStones.value.white;
// Switch to territory display mode
showTerritoryMode.value = true;
// Convert territory arrays to Sets of position keys for viewport
if (viewport) {
const blackDomain = new Set<string>();
const whiteDomain = new Set<string>();
// Use the calculated territory positions from the territory result
territory.blackTerritory.forEach((pos) => {
const key = `${pos.x},${pos.y},${pos.z}`;
blackDomain.add(key);
});
territory.whiteTerritory.forEach((pos) => {
const key = `${pos.x},${pos.y},${pos.z}`;
whiteDomain.add(key);
});
// Set domain data and show both domains
viewport.setDomainData(blackDomain, whiteDomain);
viewport.setBlackDomainVisible(true);
viewport.setWhiteDomainVisible(true);
}
console.log("Territory computed:", territory);
};
const previousMove = () => {
const success = gameStore.undoMove();
if (success && viewport) {
// Rebuild viewport from current board state
syncViewportWithStore();
}
};
const nextMove = () => {
const success = gameStore.redoMove();
if (success && viewport) {
// Rebuild viewport from current board state
syncViewportWithStore();
}
};
const jumpToMove = (index: number) => {
const success = gameStore.jumpToMove(index);
if (success && viewport) {
// Rebuild viewport from current board state
syncViewportWithStore();
// Exit territory mode when navigating move history
viewport.hideDomainCubes();
showTerritoryMode.value = false;
// Reset scores to 0 when exiting territory mode
blackScore.value = 0;
whiteScore.value = 0;
}
};
// Sync viewport with store's board state
const syncViewportWithStore = () => {
if (!viewport) return;
// Clear viewport
viewport.clearBoard();
// Read the actual board state from the store (which has captures applied)
const board = gameStore.board;
const shape = boardShape.value;
// Add all stones that exist on the board
for (let x = 0; x < shape.x; x++) {
for (let y = 0; y < shape.y; y++) {
for (let z = 0; z < shape.z; z++) {
const stone = board[x][y][z];
if (stone === Stone.Black) {
viewport.addStone(x, y, z, "black");
} else if (stone === Stone.White) {
viewport.addStone(x, y, z, "white");
}
}
}
}
// Set the last placed stone highlight based on current move index
// currentMoveIndex represents the number of moves applied (not array index)
// So the last applied move is at array index (currentMoveIndex - 1)
if (currentMoveIndex.value > 0 && currentMoveIndex.value <= moveHistory.value.length) {
const lastMove = moveHistory.value[currentMoveIndex.value - 1];
viewport.setLastPlacedStone(lastMove.x, lastMove.y, lastMove.z);
} else {
// No moves applied or at START position
viewport.setLastPlacedStone(null, null, null);
}
};
// ===== VS People Mode: Nickname Management =====
// Computed properties for multiplayer
const opponentPlayerColor = computed(() => {
if (!playerStore.playerColor) return null;
return playerStore.playerColor === "black" ? "white" : "black";
});
const isLocalPlayerTurn = computed(() => {
return currentPlayer.value === playerStore.playerColor;
});
const connectionStatusClass = computed(() => {
return playerStore.connectionStatus === "in-room" ? "connected" : "disconnected";
});
const connectionStatusText = computed(() => {
if (playerStore.connectionStatus === "in-room") return "Connected";
if (playerStore.connectionStatus === "connected") return "Joining room...";
return "Disconnected";
});
const connectionStatusIcon = computed(() => {
if (playerStore.connectionStatus === "in-room") return "🟢";
if (playerStore.connectionStatus === "connected") return "🟡"; // Yellow while joining
return "🔴";
});
// Update local player nickname
const updateLocalNickname = (newNickname: string) => {
const success = playerStore.setNickname(newNickname);
if (!success) {
console.error("Failed to update nickname");
return;
}
// If in a room, broadcast nickname change to opponents
if (playerStore.isInRoom) {
socketApi.changeNickname(newNickname, (response: any) => {
if (!response.success) {
console.error("Failed to broadcast nickname change:", response.error);
alert(`Failed to change nickname: ${response.error}`);
}
});
}
};
// Copy room code to clipboard
const copyRoomCode = () => {
if (!playerStore.roomId) return;
navigator.clipboard
.writeText(playerStore.roomId)
.then(() => alert("Room code copied to clipboard!"))
.catch((err) => console.error("Failed to copy room code:", err));
};
// ===== Room Management Functions (VS People Mode) =====
/**
* Wait for socket connection with timeout
* Prevents race condition where hash is read before socket connects
*/
async function waitForSocketConnection(timeout = 5000): Promise<void> {
return new Promise((resolve, reject) => {
if (socketApi.socket.connected) {
resolve();
return;
}
const checkInterval = setInterval(() => {
if (socketApi.socket.connected) {
clearInterval(checkInterval);
clearTimeout(timeoutHandle);
resolve();
}
}, 100);
const timeoutHandle = setTimeout(() => {
clearInterval(checkInterval);
reject(new Error("Socket connection timeout"));
}, timeout);
});
}
/**
* Initialize multiplayer room based on URL hash
* - No hash: create new room
* - Valid hash: join existing room
* - Invalid hash: redirect to create new room
*/
async function initializeMultiplayerRoom() {
console.log("[TrigoView] ========== INITIALIZE MULTIPLAYER ROOM ==========");
console.log("[TrigoView] isJoiningRoom:", isJoiningRoom.value);
console.log("[TrigoView] Socket connected:", socketApi.socket.connected);
console.log("[TrigoView] Socket ID:", socketApi.socket.id);
if (isJoiningRoom.value) {
console.log("[TrigoView] Already joining, skipping");
return; // Prevent duplicate joins
}
const hashRoomId = getRoomIdFromHash();
console.log("[TrigoView] hashRoomId from URL:", hashRoomId);
if (!hashRoomId) {
// No hash: create new room
console.log("[TrigoView] No hash found, creating new room");
await createAndJoinRoom();
} else if (isValidRoomId(hashRoomId)) {
// Valid hash: join existing room
console.log("[TrigoView] Valid hash found, joining room:", hashRoomId);
await joinRoomByHash(hashRoomId);
} else {
// Invalid hash: redirect to create new room
console.warn(`[TrigoView] Invalid room ID in hash: ${hashRoomId}`);
clearHash();
await createAndJoinRoom();
}
}
/**
* Create a new room and join it
*/
async function createAndJoinRoom() {
console.log("[TrigoView] createAndJoinRoom called, isJoiningRoom:", isJoiningRoom.value);
if (isJoiningRoom.value) {
console.log("[TrigoView] Already joining, returning");
return;
}
isJoiningRoom.value = true;
console.log("[TrigoView] Set isJoiningRoom to true");
try {
console.log("[TrigoView] Waiting for socket connection...");
await waitForSocketConnection();
console.log("[TrigoView] Socket connection ready, calling joinRoom");
socketApi.joinRoom({ nickname: playerStore.nickname }, (response: any) => {
console.log("[TrigoView] joinRoom callback received:", response);
isJoiningRoom.value = false;
if (response.success !== false && response.roomId) {
playerStore.joinRoom(response.roomId, response.playerColor);
updateHash(response.roomId);
console.log(`[TrigoView] Room created: ${response.roomId}`);
} else {
console.error("[TrigoView] Failed to create room:", response.error);
alert("Failed to create room. Please try again.");
}
});
} catch (err) {
isJoiningRoom.value = false;
console.error("[TrigoView] Socket connection failed:", err);
alert("Connection failed. Please check your internet and try again.");
}
}
/**
* Join an existing room by room ID from hash
*/
async function joinRoomByHash(roomId: string) {
if (isJoiningRoom.value) return;
isJoiningRoom.value = true;
try {
await waitForSocketConnection();
socketApi.joinRoom({ roomId, nickname: playerStore.nickname }, (response: any) => {
isJoiningRoom.value = false;
if (response.success !== false && response.roomId) {
playerStore.joinRoom(response.roomId, response.playerColor);
console.log(`[TrigoView] Room joined: ${response.roomId}`);
// Extract opponent from players list
if (response.players && socketApi.socket.id) {
for (const [playerId, player] of Object.entries(response.players) as [string, any][]) {
if (playerId !== socketApi.socket.id) {
playerStore.setOpponentNickname(player.nickname);
console.log(`[TrigoView] Opponent found: ${player.nickname}`);
break;
}
}
}
// Start game if both players are present
const playerCount = response.players ? Object.keys(response.players).length : 0;
if (playerCount >= 2 && !gameStarted.value) {
gameStore.startGame();
if (viewport) {
viewport.setGameActive(true);
}
console.log("[TrigoView] Game started - both players present");
}
} else {
console.warn(`[TrigoView] Failed to join room:`, response.error);
handleJoinFailure(response.error || "Failed to join room");
}
});
} catch (err) {
isJoiningRoom.value = false;
console.error("[TrigoView] Socket connection failed:", err);
alert("Connection failed. Please check your internet and try again.");
}
}
/**
* Handle room join failure
* Clears invalid hash and creates new room
*/
function handleJoinFailure(error: string) {
clearHash();
if (error.includes("not found")) {
alert("Room not found. Creating a new room...");
} else if (error.includes("full")) {
alert("This room is full. Creating a new room...");
} else {
alert(`Failed to join room: ${error}. Creating a new room...`);
}
createAndJoinRoom();
}
/**
* Handle browser navigation (back/forward) and manual hash edits
*/
function handleHashChange() {
if (gameMode.value !== "vs-people") return;
const currentHash = getRoomIdFromHash();
const currentRoom = playerStore.roomId;
if (currentHash !== currentRoom) {
if (currentHash && isValidRoomId(currentHash)) {
// User navigated to different room hash
if (currentRoom) socketApi.leaveRoom();
joinRoomByHash(currentHash);
} else {
// Invalid hash or removed hash
if (currentRoom) {
updateHash(currentRoom); // Restore correct hash
} else {
createAndJoinRoom(); // Create new room
}
}
}
}
// ===== Watchers =====
// Watch for current player changes to update viewport preview
watch(currentPlayer, (newPlayer) => {
if (viewport) {
viewport.setCurrentPlayer(newPlayer);
}
});
// Watch currentMoveIndex to auto-scroll move history to keep current move visible
watch(currentMoveIndex, () => {
// Use nextTick to ensure DOM is updated before scrolling
nextTick(() => {
if (moveHistoryContainer.value) {
// Find the active move element
const activeElement = moveHistoryContainer.value.querySelector(".active");
if (activeElement) {
// Scroll the active element into view smoothly
activeElement.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
});
});
// Watch playerStore.roomId to sync URL hash (VS People mode)
watch(
() => playerStore.roomId,
(newRoomId) => {
if (gameMode.value !== "vs-people") return;
if (newRoomId) {
updateHash(newRoomId);
} else {
clearHash();
}
}
);
// Watch TGN modal to populate editable content when opened
watch(showTGNModal, (isVisible) => {
if (isVisible) {
// Populate with current game's TGN when modal opens
editableTGNContent.value = tgnContent.value;
tgnValidationState.value = "valid"; // Current TGN is always valid
tgnValidationError.value = "";
}
});
// Lifecycle hooks
onMounted(() => {
console.log("TrigoDemo component mounted");
// In VS People mode, always start fresh (server maintains authoritative state)
// In other modes, try to restore from session storage
let restoredFromStorage = false;
if (gameMode.value !== "vs-people") {
restoredFromStorage = gameStore.loadFromSessionStorage();
}
// If not restored from storage, initialize new game
if (!restoredFromStorage) {
// Parse initial board shape
const shape = parseBoardShape(selectedBoardShape.value);
// Initialize game store
gameStore.initializeGame(shape);
} else {
// Update selected board shape to match restored state
selectedBoardShape.value = printBoardShape(boardShape.value);
console.log("Restored game state from session storage");
}
// Initialize Three.js viewport with current board shape
if (viewportCanvas.value) {
viewport = new TrigoViewport(viewportCanvas.value, boardShape.value, {
onStoneClick: handleStoneClick,
onPositionHover: handlePositionHover,
isPositionDroppable: isPositionDroppable,
onInspectGroup: handleInspectGroup
});
console.log("TrigoViewport initialized");
// Hide loading overlay after viewport is initialized
isLoading.value = false;
// If game was restored, sync viewport with restored board state
if (restoredFromStorage) {
syncViewportWithStore();
viewport.setGameActive(isGameActive.value);
}
// Setup ResizeObserver to handle canvas resizing when sidebar expands/collapses
resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target === viewportCanvas.value && viewport) {
// Trigger viewport resize
const rect = viewportCanvas.value.getBoundingClientRect();
//console.log(`Canvas resized: ${rect.width}x${rect.height}`);
// Call the viewport's resize handler directly
// The onWindowResize method updates camera aspect and renderer size
(viewport as any).onWindowResize();
}
}
});
// Observe the canvas element for size changes
resizeObserver.observe(viewportCanvas.value);
console.log("ResizeObserver attached to canvas");
}
// Start game automatically if not restored or was playing
if (!restoredFromStorage || gameStore.gameStatus === "idle") {
gameStore.startGame();
if (viewport) {
viewport.setGameActive(true);
}
}
// Initialize AI agent in VS AI mode
if (gameMode.value === "vs-ai") {
console.log("[TrigoView] Initializing AI agent for VS AI mode...");
aiAgent.initialize().catch((err) => {
console.error("[TrigoView] Failed to initialize AI agent:", err);
});
}
// Setup socket listeners for VS People mode
if (gameMode.value === "vs-people") {
console.log("[TrigoView] Setting up socket listeners for VS People mode...");
// Listen for nickname changes
socketApi.onNicknameChanged((data) => {
console.log(
`[TrigoView] Player ${data.playerId} changed nickname: ${data.oldNickname} -> ${data.nickname}`
);
// If it's not us, update opponent nickname
if (data.playerId !== socketApi.socket.id) {
playerStore.setOpponentNickname(data.nickname);
}
});
// Listen for game updates (moves, passes, undo, redo)
socketApi.onGameUpdate((data) => {
console.log("[TrigoView] Game update received:", data);
// Update current player from server
if (data.currentPlayer) {
gameStore.setCurrentPlayer(data.currentPlayer);
}
// Handle different actions
if (data.action === "move" && data.lastMove) {
const { x, y, z } = data.lastMove;
// Apply move to local game state
const result = gameStore.makeMove(x, y, z);
if (result.success && viewport) {
// Add stone to viewport
const stoneColor = gameStore.opponentPlayer;
viewport.addStone(x, y, z, stoneColor);
// Remove captured stones
if (data.capturedPositions && data.capturedPositions.length > 0) {
data.capturedPositions.forEach((pos: { x: number; y: number; z: number }) => {
viewport.removeStone(pos.x, pos.y, pos.z);
});
console.log(`[TrigoView] Captured ${data.capturedPositions.length} stone(s)`);
}
// Hide territory visualization
viewport.hideDomainCubes();
showTerritoryMode.value = false;
}
} else if (data.action === "pass") {
gameStore.pass();
console.log("[TrigoView] Pass received from server");
} else if (data.action === "undo" || data.action === "redo") {
// For undo/redo, reload game from TGN
if (data.tgn && viewport) {
gameStore.loadFromTGN(data.tgn);
// Resync viewport with game state
viewport.clearBoard();
syncViewportWithGame();
}
}
});
// Listen for player joined
socketApi.onPlayerJoined((data) => {
console.log("[TrigoView] Player joined:", data);
playerStore.setOpponentNickname(data.nickname);
// Start game when second player joins
if (!gameStarted.value) {
gameStore.startGame();
if (viewport) {
viewport.setGameActive(true);
}
console.log("[TrigoView] Game started - opponent joined");
}
});
// Listen for player left
socketApi.onPlayerLeft((data) => {
console.log("[TrigoView] Player left:", data);
playerStore.setOpponentNickname(null);
});
// Listen for player disconnected
socketApi.onPlayerDisconnected((data) => {
console.log("[TrigoView] Player disconnected:", data);
if (data.playerId !== socketApi.socket.id) {
playerStore.setOpponentNickname(null);
}
});
// Listen for game ended
socketApi.onGameEnded((data) => {
console.log("[TrigoView] Game ended:", data);
alert(`Game ended! Winner: ${data.winner || "None"}\nReason: ${data.reason}`);
});
// Listen for game reset
socketApi.onGameReset((data) => {
console.log("[TrigoView] Game reset:", data);
// Reload game state
if (data.tgn) {
gameStore.loadFromTGN(data.tgn);
} else {
gameStore.resetGame(boardShape.value);
}
if (viewport) {
viewport.clearBoard();
}
// Update player colors if changed
if (data.players && socketApi.socket.id) {
const myPlayer = data.players[socketApi.socket.id];
if (myPlayer) {
playerStore.playerColor = myPlayer.color;
}
}
});
// Listen for errors
socketApi.onError((data) => {
console.error("[TrigoView] Socket error:", data.message);
});
// Set player ID when socket connects
if (socketApi.socket.id) {
playerStore.setPlayerId(socketApi.socket.id);
}
// Listen for browser navigation (back/forward) and manual hash edits
window.addEventListener("hashchange", handleHashChange);
// Handle socket connection and reconnection
socketApi.socket.on("connect", () => {
console.log("[TrigoView] Socket connected in VS People mode");
// Update player ID and connection status
if (socketApi.socket.id) {
playerStore.setPlayerId(socketApi.socket.id);
}
// Initialize room based on URL hash (only if not already in a room)
if (!playerStore.roomId && !isJoiningRoom.value) {
console.log("[TrigoView] Initializing room after socket connection");
initializeMultiplayerRoom();
}
});
// Handle socket disconnection
socketApi.socket.on("disconnect", () => {
console.log("[TrigoView] Socket disconnected in VS People mode");
// Don't reset roomId - we want to rejoin on reconnect
if (playerStore.roomId) {
playerStore.connectionStatus = "connected"; // Still have room info, just disconnected
} else {
playerStore.disconnect();
}
});
// If socket is already connected, initialize room immediately
if (socketApi.socket.connected) {
console.log("[TrigoView] Socket already connected, initializing room");
initializeMultiplayerRoom();
}
}
// Add keyboard shortcuts
window.addEventListener("keydown", handleKeyPress);
});
// Watch for game mode changes and initialize AI when switching to vs-ai
watch(gameMode, (newMode, oldMode) => {
console.log(`[TrigoView] Game mode changed: ${oldMode} -> ${newMode}`);
if (newMode === "vs-ai" && !aiReady.value) {
console.log("[TrigoView] Switching to VS AI mode, initializing AI agent...");
aiAgent.initialize()
.then(() => {
// After AI is ready, if it's AI's turn, generate move
if (gameStarted.value && currentPlayer.value === aiPlayerColor.value) {
setTimeout(() => {
generateAIMove();
}, 100);
}
})
.catch((err) => {
console.error("[TrigoView] Failed to initialize AI agent:", err);
});
}
});
onUnmounted(() => {
console.log("TrigoDemo component unmounted");
// Remove keyboard shortcuts
window.removeEventListener("keydown", handleKeyPress);
// Cleanup socket listeners for VS People mode
if (gameMode.value === "vs-people") {
console.log("[TrigoView] Cleaning up socket listeners...");
socketApi.offNicknameChanged();
socketApi.offGameUpdate();
socketApi.offPlayerJoined();
socketApi.offPlayerLeft();
socketApi.offPlayerDisconnected();
socketApi.offGameEnded();
socketApi.offGameReset();
socketApi.offError();
// Remove hashchange listener
window.removeEventListener("hashchange", handleHashChange);
}
// Disconnect ResizeObserver
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
console.log("ResizeObserver disconnected");
}
// Cleanup Three.js resources
if (viewport) {
viewport.destroy();
viewport = null;
}
});
// Keyboard shortcuts handler
const handleKeyPress = (event: KeyboardEvent) => {
// Ignore if typing in an input field
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
switch (event.key.toLowerCase()) {
case "p": // Pass
if (gameStarted.value) {
pass();
}
break;
case "n": // New game
newGame();
break;
case "r": // Resign
if (gameStarted.value) {
resign();
}
break;
case "arrowleft": // Previous move
previousMove();
event.preventDefault();
break;
case "arrowright": // Next move
nextMove();
event.preventDefault();
break;
case "t": // Compute territory
if (gameStarted.value) {
computeTerritory();
}
break;
}
};
// TGN Modal Methods
const selectAllTGN = (event: Event) => {
const textarea = event.target as HTMLTextAreaElement;
textarea.select();
};
const copyTGN = async () => {
try {
// Try modern clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(tgnContent.value);
alert("TGN copied to clipboard!");
} else {
// Fallback for older browsers or non-secure contexts
const textarea = document.createElement("textarea");
textarea.value = tgnContent.value;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand("copy");
alert("TGN copied to clipboard!");
} catch (fallbackErr) {
console.error("Fallback copy failed:", fallbackErr);
alert("Failed to copy TGN. Please select and copy manually.");
} finally {
document.body.removeChild(textarea);
}
}
} catch (err) {
console.error("Failed to copy TGN:", err);
alert("Failed to copy TGN. Please select and copy manually.");
}
};
</script>
<style lang="scss" scoped>
.trigo-view {
display: flex;
flex-direction: column;
height: 100%;
background-color: #404040;
color: #e0e0e0;
overflow: hidden;
.view-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 2rem;
background: linear-gradient(135deg, #505050 0%, #454545 100%);
border-bottom: 2px solid #606060;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
.view-status {
display: flex;
gap: 2rem;
align-items: center;
.turn-indicator {
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s ease;
&.black {
background-color: #2c2c2c;
color: #fff;
box-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
}
&.white {
background-color: #f0f0f0;
color: #000;
box-shadow: 0 0 10px rgba(240, 240, 240, 0.3);
}
}
.move-count {
font-size: 1rem;
color: #a0a0a0;
}
// VS AI Mode Styles
&.ai-mode {
.player-info,
.ai-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: #3a3a3a;
border-radius: 8px;
border: 2px solid transparent;
transition: all 0.3s ease;
&.on-turn {
background-color: #4a4a4a;
border-color: #60a5fa;
box-shadow: 0 0 12px rgba(96, 165, 250, 0.4);
}
}
.player-label,
.ai-indicator {
color: #a0a0a0;
font-weight: 500;
}
.stone-icon {
width: 20px;
height: 20px;
border-radius: 50%;
flex-shrink: 0;
&.black {
background-color: #2c2c2c;
border: 2px solid #fff;
}
&.white {
background-color: #f0f0f0;
border: 2px solid #000;
}
}
.ai-level {
color: #60a5fa;
font-weight: 600;
&.ai-thinking {
color: #fbbf24;
animation: pulse 1.5s ease-in-out infinite;
}
&.ai-error {
color: #ef4444;
}
}
.ai-time {
color: #9ca3af;
font-size: 0.875rem;
font-style: italic;
}
.btn-swap-colors {
padding: 0.4rem 0.8rem;
background-color: #4b5563;
color: #e5e7eb;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 600;
transition: all 0.2s ease;
&:hover {
background-color: #6b7280;
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
}
}
}
// VS People Mode Styles
&.people-mode {
.room-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background-color: #3a3a3a;
border-radius: 8px;
}
.room-label {
color: #a0a0a0;
font-weight: 500;
}
.room-code {
color: #e94560;
font-weight: 700;
font-family: monospace;
font-size: 1.1rem;
}
.btn-copy-room {
padding: 0.25rem 0.5rem;
background-color: transparent;
border: 1px solid #606060;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
&:hover {
background-color: #505050;
border-color: #808080;
}
}
.players-info {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 1rem;
background-color: #3a3a3a;
border-radius: 8px;
}
.player-display {
transition: all 0.3s ease;
&.on-turn {
filter: brightness(1.3);
}
}
.player-name {
color: #e0e0e0;
font-weight: 600;
}
.waiting-text {
color: #9ca3af;
font-style: italic;
}
.vs-divider {
color: #606060;
font-weight: 500;
}
.connection-status {
padding: 0.5rem 1rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
&.connected {
background-color: rgba(74, 222, 128, 0.15);
color: #4ade80;
}
&.disconnected {
background-color: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
}
}
// Library Mode Styles
&.library-mode {
.game-info {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 1rem;
background-color: #3a3a3a;
border-radius: 8px;
}
.game-title {
color: #e0e0e0;
font-weight: 600;
font-size: 1.1rem;
}
.game-date {
color: #a0a0a0;
font-weight: 500;
font-family: monospace;
}
.library-actions {
display: flex;
gap: 0.5rem;
}
.btn-library {
padding: 0.5rem 1rem;
border: none;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.3s ease;
&.btn-load {
background-color: #2d4059;
color: #e0e0e0;
&:hover {
background-color: #3d5069;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(45, 64, 89, 0.4);
}
}
&.btn-export {
background-color: #e94560;
color: #fff;
&:hover {
background-color: #f95670;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4);
}
}
}
}
}
}
.header-controls {
display: flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
.board-shape-select,
.color-preference-select {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 4px;
color: #fff;
padding: 0.4rem 0.6rem;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: rgba(0, 0, 0, 0.4);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
// Ensure dropdown options are readable
option {
background: #3a3a3a;
color: #e0e0e0;
}
}
.color-preference-select {
min-width: 130px;
}
.board-shape-select {
min-width: 90px;
}
.btn-reset {
background: #e94560;
color: white;
border: none;
border-radius: 4px;
padding: 0.4rem 1rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
&:hover {
background: #d63850;
}
&:active {
background: #c02040;
}
}
}
.view-body {
display: flex;
flex: 1;
overflow: hidden;
.board-container {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: #484848;
padding: 1rem;
position: relative;
.viewport-wrapper {
width: 100%;
height: 100%;
position: relative;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
.viewport-canvas {
width: 100%;
height: 100%;
display: block;
background-color: #50505a;
}
.viewport-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
.loading-text {
color: rgba(255, 255, 255, 0.5);
font-size: 1.2rem;
animation: pulse 2s ease-in-out infinite;
}
}
.inspect-tooltip {
position: absolute;
top: 1rem;
left: 1rem;
background-color: rgba(255, 241, 176, 0.95);
color: #111;
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 600;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
z-index: 100;
.tooltip-content {
display: flex;
align-items: center;
gap: 0.5rem;
.tooltip-label {
white-space: nowrap;
}
.tooltip-divider {
color: #888;
}
}
}
}
}
.controls-panel {
width: 320px;
background-color: #3a3a3a;
border-left: 2px solid #606060;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.3);
.panel-section {
padding: 0.8rem;
border-bottom: 1px solid #505050;
position: relative;
.section-title {
font-size: 0.7rem;
font-weight: 600;
color: #f0bcc5;
text-transform: uppercase;
letter-spacing: 1px;
position: absolute;
top: 0;
left: 0.8rem;
opacity: 0;
transition: opacity 0.3s ease-in;
}
&:hover .section-title {
opacity: 0.6;
}
}
.score-section {
// Default style (captured mode) - lighter background
.score-display {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
.score-button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 1rem;
border: 2px solid #505050;
border-radius: 8px;
background-color: #484848;
cursor: default;
transition: all 0.3s ease;
&.black {
.color-indicator {
background-color: #2c2c2c;
border: 2px solid #fff;
}
}
&.white {
.color-indicator {
background-color: #f0f0f0;
border: 2px solid #000;
}
}
.color-indicator {
width: 24px;
height: 24px;
border-radius: 50%;
}
.score {
font-size: 1.5rem;
font-weight: 700;
color: #e0e0e0;
}
&:disabled {
opacity: 0.5;
}
}
}
.compute-territory {
width: 100%;
padding: 0.75rem;
background-color: #505050;
color: #e0e0e0;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
&:hover:not(:disabled) {
background-color: #606060;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// Territory mode - darker background with glow
&.show-territory {
.score-display .score-button {
background-color: #3a3a3a;
border-color: #606060;
box-shadow: 0 0 12px rgba(233, 69, 96, 0.3);
}
}
}
.routine-section {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
.routine-content {
flex: 1;
overflow-y: auto;
background-color: #2a2a2a;
border-radius: 8px;
padding: 0.5rem;
.move-list {
padding: 0;
margin: 0;
.move-row {
display: grid;
grid-template-columns: 30px 1fr 1fr;
gap: 0.25rem;
margin-bottom: 0.25rem;
align-items: center;
&.start-row {
grid-template-columns: 30px 1fr;
padding: 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: #484848;
}
&.active {
background-color: #505050;
border-left: 3px solid #e94560;
}
.open-label {
font-weight: 700;
color: #e94560;
text-transform: uppercase;
letter-spacing: 1px;
}
}
.round-number {
color: #808080;
font-size: 0.9em;
text-align: right;
padding-right: 0.25rem;
}
.move-cell {
padding: 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.5rem;
background-color: #1a1a1a;
&:hover:not(.empty) {
background-color: #484848;
}
&.active {
background-color: #505050;
border-left: 3px solid #e94560;
}
&.empty {
background-color: transparent;
cursor: default;
}
.stone-icon {
width: 16px;
height: 16px;
border-radius: 50%;
flex-shrink: 0;
&.black {
background-color: #2c2c2c;
border: 2px solid #fff;
}
&.white {
background-color: #f0f0f0;
border: 2px solid #000;
}
}
.move-coords {
color: #a0a0a0;
font-family: monospace;
font-size: 0.9em;
}
.move-label {
color: #60a5fa;
font-weight: 600;
font-size: 0.85em;
text-transform: lowercase;
}
}
}
}
}
}
.controls-section {
.control-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
.play-controls,
.history-controls {
display: flex;
gap: 0.5rem;
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
flex: 1;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.btn-pass {
background-color: #2d4059;
color: #e0e0e0;
&:hover:not(:disabled) {
background-color: #3d5069;
}
}
&.btn-resign {
background-color: #c23b22;
color: #fff;
&:hover:not(:disabled) {
background-color: #d44b32;
}
}
&.btn-icon {
background-color: #505050;
color: #e0e0e0;
padding: 0.75rem;
font-size: 1.2rem;
&:hover:not(:disabled) {
background-color: #606060;
}
}
}
}
}
}
}
}
/* TGN Button and Modal Styles */
.btn-view-tgn {
position: absolute;
top: 0;
right: 0.8rem;
padding: 0.25rem 0.5rem;
background-color: transparent;
color: #a0a0a0;
border: 1px solid #505050;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
transition: all 0.2s ease;
opacity: 0;
&:hover {
background-color: #505050;
color: #e0e0e0;
border-color: #606060;
}
}
.routine-section:hover .btn-view-tgn {
opacity: 1;
}
.tgn-modal {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
backdrop-filter: blur(4px);
.tgn-modal-content {
background-color: #3a3a3a;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
.tgn-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
background-color: #2a2a2a;
border-bottom: 1px solid #505050;
h3 {
margin: 0;
font-size: 1.2rem;
font-weight: 600;
color: #e0e0e0;
}
.tgn-status {
font-size: 0.85rem;
font-weight: 600;
padding: 0.4rem 0.8rem;
border-radius: 4px;
white-space: nowrap;
transition: all 0.3s ease;
&.idle {
background-color: #505050;
color: #a0a0a0;
}
&.valid {
background-color: rgba(74, 222, 128, 0.15);
color: #4ade80;
border: 1px solid rgba(74, 222, 128, 0.4);
}
&.invalid {
background-color: rgba(239, 68, 68, 0.15);
color: #ef4444;
border: 1px solid rgba(239, 68, 68, 0.4);
}
}
.btn-close {
background: none;
border: none;
color: #a0a0a0;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: all 0.2s ease;
&:hover {
background-color: #505050;
color: #e0e0e0;
}
}
}
.tgn-modal-body {
flex: 1;
padding: 1.5rem;
overflow: auto;
.tgn-textarea {
width: 100%;
height: 100%;
min-height: 300px;
background-color: #2a2a2a;
color: #e0e0e0;
border: 2px solid #505050;
border-radius: 8px;
padding: 1rem;
font-family: "Courier New", Courier, monospace;
font-size: 0.9rem;
line-height: 1.6;
resize: vertical;
cursor: text;
transition:
border-color 0.3s ease,
box-shadow 0.3s ease;
&:focus {
outline: none;
border-color: #e94560;
}
&.valid {
border-color: #4ade80;
box-shadow: 0 0 8px rgba(74, 222, 128, 0.2);
}
&.invalid {
border-color: #ef4444;
box-shadow: 0 0 8px rgba(239, 68, 68, 0.2);
}
&.idle {
border-color: #505050;
}
}
}
.tgn-modal-footer {
display: flex;
gap: 0.5rem;
padding: 1rem 1.5rem;
background-color: #2a2a2a;
border-top: 1px solid #505050;
.btn {
flex: 1;
padding: 0.75rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&.btn-apply {
background-color: #4ade80;
color: #000;
&:hover:not(:disabled) {
background-color: #22c55e;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(74, 222, 128, 0.4);
}
}
&.btn-copy {
background-color: #e94560;
color: #fff;
&:hover {
background-color: #f95670;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4);
}
}
&.btn-close-modal {
background-color: #505050;
color: #e0e0e0;
&:hover {
background-color: #606060;
}
}
}
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.5;
}
50% {
opacity: 1;
}
}
/* Scrollbar styling */
.controls-panel {
&::-webkit-scrollbar {
width: 8px;
}
&::-webkit-scrollbar-track {
background: #2a2a2a;
}
&::-webkit-scrollbar-thumb {
background: #505050;
border-radius: 4px;
&:hover {
background: #606060;
}
}
}
.routine-content {
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: #2a2a2a;
}
&::-webkit-scrollbar-thumb {
background: #505050;
border-radius: 3px;
&:hover {
background: #606060;
}
}
}
// Animations
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
</style>