Spaces:
Sleeping
Sleeping
| <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">ABC123</span> | |
| </div> | |
| <div class="players-info"> | |
| <span class="player-name">You (Black)</span> | |
| <span class="vs-divider">vs</span> | |
| <span class="player-name">Waiting...</span> | |
| </div> | |
| <span class="connection-status connected">🟢 Connected</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> | |
| </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> | |
| <!-- Game Settings --> | |
| <div class="panel-section settings-section"> | |
| <h3 class="section-title">Settings</h3> | |
| <div class="settings-content"> | |
| <div class="setting-item"> | |
| <label for="board-shape"> | |
| Board Shape: | |
| <span | |
| v-if="isBoardShapeDirty" | |
| class="dirty-indicator" | |
| title="Board shape will change on next game" | |
| >*</span | |
| > | |
| </label> | |
| <select id="board-shape" v-model="selectedBoardShape"> | |
| <option value="3*3*3">3×3×3</option> | |
| <option value="5*5*5">5×5×5</option> | |
| <option value="7*7*7">7×7×7</option> | |
| <option value="9*9*1">9×9×1 (2D)</option> | |
| <option value="13*13*1">13×13×1 (2D)</option> | |
| <option value="19*19*1">19×19×1 (2D)</option> | |
| <option value="9*9*2">9×9×2</option> | |
| </select> | |
| </div> | |
| <button class="btn btn-primary btn-new-game" @click="newGame"> | |
| {{ gameStarted ? "Reset Game" : "Start Game" }} | |
| </button> | |
| </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 { useTrigoAgent } from "@/composables/useTrigoAgent"; | |
| 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"); | |
| // 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]); | |
| }; | |
| // Use game store | |
| const gameStore = useGameStore(); | |
| const { | |
| currentPlayer, | |
| moveHistory, | |
| currentMoveIndex, | |
| capturedStones, | |
| gameStatus, | |
| boardShape, | |
| moveCount, | |
| isGameActive, | |
| passCount | |
| } = storeToRefs(gameStore); | |
| // 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")); | |
| // 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) { | |
| 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 chose to pass"); | |
| // Handle AI pass if needed | |
| } | |
| } catch (err) { | |
| console.error("[TrigoView] Failed to generate AI move:", err); | |
| } | |
| }; | |
| // Handle stone placement | |
| const handleStoneClick = async (x: number, y: number, z: number) => { | |
| if (!gameStarted.value) return; | |
| // Make move in store | |
| 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); | |
| } | |
| }; | |
| const pass = () => { | |
| 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); | |
| } | |
| }; | |
| // 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 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"); | |
| // Try to restore game state from session storage | |
| const 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); | |
| }); | |
| } | |
| // 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); | |
| // 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: center; | |
| 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; | |
| } | |
| .players-info { | |
| display: flex; | |
| align-items: center; | |
| gap: 0.75rem; | |
| padding: 0.5rem 1rem; | |
| background-color: #3a3a3a; | |
| border-radius: 8px; | |
| } | |
| .player-name { | |
| color: #e0e0e0; | |
| font-weight: 600; | |
| } | |
| .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); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| .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; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| .settings-section { | |
| .settings-content { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1rem; | |
| .setting-item { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| label { | |
| font-weight: 600; | |
| color: #a0a0a0; | |
| display: flex; | |
| align-items: center; | |
| gap: 0.3rem; | |
| .dirty-indicator { | |
| color: #e94560; | |
| font-size: 1.2rem; | |
| font-weight: 700; | |
| animation: pulse 2s ease-in-out infinite; | |
| } | |
| } | |
| select { | |
| padding: 0.5rem; | |
| border: 2px solid #505050; | |
| border-radius: 8px; | |
| background-color: #484848; | |
| color: #e0e0e0; | |
| cursor: pointer; | |
| font-weight: 600; | |
| &:disabled { | |
| opacity: 0.5; | |
| cursor: not-allowed; | |
| } | |
| } | |
| } | |
| .btn-new-game { | |
| width: 100%; | |
| padding: 0.75rem; | |
| background-color: #e94560; | |
| color: #fff; | |
| border: none; | |
| border-radius: 8px; | |
| font-weight: 700; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| text-transform: uppercase; | |
| &:hover { | |
| background-color: #f95670; | |
| transform: translateY(-2px); | |
| box-shadow: 0 4px 12px rgba(233, 69, 96, 0.4); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| /* 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> | |