trigo / trigo-web /app /src /views /TrigoView.vue
k-l-lambda's picture
fix onnx model loading
4b26793
<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>