diff --git a/src/Tutorial.css b/src/Tutorial.css
new file mode 100644
index 0000000..eb9b069
--- /dev/null
+++ b/src/Tutorial.css
@@ -0,0 +1,122 @@
+.tutorial-container {
+ display: flex;
+ min-height: 100vh;
+ background: var(--bg-primary);
+ color: var(--text-light);
+}
+
+.tutorial-board {
+ flex: 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ padding: 1rem;
+}
+
+.tutorial-panel {
+ width: 300px;
+ padding: 1rem;
+ background: rgba(255, 255, 255, 0.1);
+ backdrop-filter: blur(20px);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.tutorial-panel button {
+ align-self: flex-start;
+ padding: 0.5rem 1rem;
+ border: 1px solid var(--border-light);
+ background: var(--surface-1);
+ cursor: pointer;
+ border-radius: var(--border-radius);
+ color: var(--text-primary);
+}
+
+.tutorial-next {
+ border-color: var(--accent-primary);
+ background: var(--accent-primary);
+ color: #fff;
+}
+
+/* Tutorial highlighting animations */
+@keyframes tutorialPulse {
+ 0% {
+ background-color: rgba(34, 197, 94, 0.3);
+ box-shadow: 0 0 5px rgba(34, 197, 94, 0.5);
+ }
+ 50% {
+ background-color: rgba(34, 197, 94, 0.6);
+ box-shadow: 0 0 15px rgba(34, 197, 94, 0.8);
+ }
+ 100% {
+ background-color: rgba(34, 197, 94, 0.3);
+ box-shadow: 0 0 5px rgba(34, 197, 94, 0.5);
+ }
+}
+
+@keyframes tutorialPulseSource {
+ 0% {
+ background-color: rgba(59, 130, 246, 0.3);
+ box-shadow: 0 0 5px rgba(59, 130, 246, 0.5);
+ }
+ 50% {
+ background-color: rgba(59, 130, 246, 0.6);
+ box-shadow: 0 0 15px rgba(59, 130, 246, 0.8);
+ }
+ 100% {
+ background-color: rgba(59, 130, 246, 0.3);
+ box-shadow: 0 0 5px rgba(59, 130, 246, 0.5);
+ }
+}
+
+@keyframes tutorialPulseAttack {
+ 0% {
+ background-color: rgba(239, 68, 68, 0.3);
+ box-shadow: 0 0 5px rgba(239, 68, 68, 0.5);
+ }
+ 50% {
+ background-color: rgba(239, 68, 68, 0.6);
+ box-shadow: 0 0 15px rgba(239, 68, 68, 0.8);
+ }
+ 100% {
+ background-color: rgba(239, 68, 68, 0.3);
+ box-shadow: 0 0 5px rgba(239, 68, 68, 0.5);
+ }
+}
+
+@keyframes tutorialPulseBlock {
+ 0% {
+ background-color: rgba(168, 85, 247, 0.3);
+ box-shadow: 0 0 5px rgba(168, 85, 247, 0.5);
+ }
+ 50% {
+ background-color: rgba(168, 85, 247, 0.6);
+ box-shadow: 0 0 15px rgba(168, 85, 247, 0.8);
+ }
+ 100% {
+ background-color: rgba(168, 85, 247, 0.3);
+ box-shadow: 0 0 5px rgba(168, 85, 247, 0.5);
+ }
+}
+
+/* Tutorial highlight CSS classes */
+.tutorial-highlight-target {
+ animation: tutorialPulse 1.5s ease-in-out infinite;
+ border: 2px solid rgba(34, 197, 94, 0.8) !important;
+}
+
+.tutorial-highlight-source {
+ animation: tutorialPulseSource 1.5s ease-in-out infinite;
+ border: 2px solid rgba(59, 130, 246, 0.8) !important;
+}
+
+.tutorial-highlight-attack {
+ animation: tutorialPulseAttack 1.5s ease-in-out infinite;
+ border: 2px solid rgba(239, 68, 68, 0.8) !important;
+}
+
+.tutorial-highlight-block {
+ animation: tutorialPulseBlock 1.5s ease-in-out infinite;
+ border: 2px solid rgba(168, 85, 247, 0.8) !important;
+}
diff --git a/src/Tutorial.tsx b/src/Tutorial.tsx
new file mode 100644
index 0000000..41f6f40
--- /dev/null
+++ b/src/Tutorial.tsx
@@ -0,0 +1,180 @@
+import React, { useMemo, useCallback, useState, useRef } from 'react';
+import { Client } from 'boardgame.io/react';
+import { createGameRules } from './game';
+import Board from './Board';
+import type { TutorialStep, TutorialMove } from './tutorialData';
+import { tutorialSteps } from './tutorialData';
+import './Tutorial.css';
+
+interface TutorialProps {
+ onExit: () => void;
+}
+
+const BoardWrapper = (
+ props: any & {
+ tutorialMove: TutorialMove;
+ onMoveDone: () => void;
+ onClientReady?: (client: any) => void;
+ }
+): React.ReactElement => {
+ // Capture client instance for auto-declarations with stable reference
+ const { onClientReady, moves, G, ctx } = props;
+ const lastNotificationRef = React.useRef
('');
+
+ React.useEffect(() => {
+ if (onClientReady && moves && G && ctx) {
+ // Create a stable key to prevent excessive notifications
+ const currentKey = `${ctx.turn}-${ctx.currentPlayer}-${ctx.phase}`;
+ if (currentKey !== lastNotificationRef.current) {
+ lastNotificationRef.current = currentKey;
+ onClientReady({ moves, G, ctx });
+ }
+ }
+ }, [moves, G, ctx, onClientReady]);
+
+ return (
+
+ );
+};
+
+function createTutorialGame(step: TutorialStep) {
+ const base = createGameRules(step.state.config!);
+ return {
+ ...base,
+ setup: () => step.state,
+ phases: {
+ place: {
+ ...(base.phases as any).place,
+ start: step.state.phase === 'place',
+ },
+ play: {
+ ...(base.phases as any).play,
+ start: step.state.phase === 'play',
+ },
+ },
+ };
+}
+
+const Tutorial: React.FC = ({ onExit }) => {
+ const [index, setIndex] = useState(0);
+ const [moveIndex, setMoveIndex] = useState(0);
+ const [stepDone, setStepDone] = useState(false);
+ const [clientInstance, setClientInstance] = useState(null);
+ const clientRef = useRef(null);
+ const autoDeclarationProcessed = useRef>(new Set());
+ const step = tutorialSteps[index];
+
+ const handleMoveDone = useCallback(() => {
+ if (moveIndex < step.moves.length - 1) {
+ setMoveIndex(i => i + 1);
+ } else {
+ setStepDone(true);
+ }
+ }, [moveIndex, step]);
+
+ const handleClientReady = useCallback((client: any) => {
+ setClientInstance(client);
+ }, []);
+
+ // Auto-handle opponent declarations
+ React.useEffect(() => {
+ if (!clientInstance || !clientInstance.G || !clientInstance.ctx) {
+ return;
+ }
+
+ const { G, ctx } = clientInstance;
+ const opponentID = '1';
+ const opponentStage = ctx.activePlayers?.[opponentID];
+
+ // Create unique key for this attack to prevent duplicate processing
+ const attackKey = G.attackTo ? `${G.attackTo[0]}-${G.attackTo[1]}-${ctx.turn}` : null;
+
+ // Only process if we haven't already handled this attack
+ if (!attackKey || autoDeclarationProcessed.current.has(attackKey)) {
+ return;
+ }
+
+ // If opponent needs to declare a response block
+ if (opponentStage === 'responseBlock' && G.attackTo && ctx.currentPlayer === '0') {
+ const targetShip = G.cells[G.attackTo[0]][G.attackTo[1]];
+ if (targetShip && (targetShip.type === 'St' || targetShip.type === 'Lk')) {
+ // Mark this attack as processed
+ autoDeclarationProcessed.current.add(attackKey);
+
+ console.log('Auto-declaring opponent ship:', targetShip.type, 'for attack:', attackKey);
+
+ // Auto-declare the target ship for the opponent after a delay
+ const timer = setTimeout(() => {
+ // Double-check the game state hasn't changed
+ if (G.attackTo && !G.responseBlock) {
+ const responseBlock = {
+ type: targetShip.type,
+ size: 1,
+ coords: [G.attackTo],
+ };
+
+ console.log('Setting responseBlock directly:', responseBlock);
+ G.responseBlock = responseBlock;
+ }
+ }, 1000);
+
+ return () => {
+ clearTimeout(timer);
+ // Don't remove from processed set in cleanup to prevent re-processing
+ };
+ }
+ }
+ }, [clientInstance]);
+
+ const nextStep = useCallback(() => {
+ // Clear processed attacks when moving to next step
+ autoDeclarationProcessed.current.clear();
+
+ if (index < tutorialSteps.length - 1) {
+ setIndex(i => i + 1);
+ setMoveIndex(0);
+ setStepDone(false);
+ setClientInstance(null); // Reset client instance for new step
+ } else {
+ onExit();
+ }
+ }, [index, onExit]);
+
+ const TutorialClient = useMemo(() => {
+ const game = createTutorialGame(step);
+ const ClientClass = Client({
+ game,
+ board: (props: any) => (
+
+ ),
+ debug: false,
+ numPlayers: 2,
+ multiplayer: false, // Use local mode for full control
+ });
+ return ClientClass;
+ }, [step, moveIndex, handleMoveDone, handleClientReady]);
+
+ return (
+
+
+
+
+
+
{step.description}
+ {stepDone && (
+
+ )}
+
+
+
+ );
+};
+
+export default Tutorial;
diff --git a/src/tutorialData.ts b/src/tutorialData.ts
new file mode 100644
index 0000000..486804d
--- /dev/null
+++ b/src/tutorialData.ts
@@ -0,0 +1,300 @@
+import deepcopy from 'deepcopy';
+import type { GameState, GameConfig } from './game';
+
+export interface TutorialMove {
+ type: 'move' | 'ready' | 'skip' | 'block';
+ from?: [number, number];
+ to?: [number, number];
+ mode?: string;
+ coords?: [number, number][];
+ expectedHighlight?: [Position, string][];
+ expectedHighlightClasses?: [Position, string][];
+}
+
+type Position = [number, number];
+
+export interface TutorialStep {
+ description: string;
+ state: GameState;
+ moves: TutorialMove[];
+}
+
+const tutorialConfig: GameConfig = {
+ name: 'Tutorial',
+ fieldSize: 5,
+ placementZoneSize: 2,
+ initialShips: [],
+};
+
+function emptyCells(size: number) {
+ return Array.from({ length: size }, () => Array.from({ length: size }, () => null));
+}
+
+function baseState(): GameState {
+ const cells = emptyCells(tutorialConfig.fieldSize);
+ // Place forts for both players so the game doesn't end immediately
+ cells[0][0] = { type: 'F', player: 0, state: {}, label: {} } as any;
+ cells[tutorialConfig.fieldSize - 1][tutorialConfig.fieldSize - 1] = {
+ type: 'F',
+ player: 1,
+ state: {},
+ label: {},
+ } as any;
+
+ return {
+ cells,
+ log: [],
+ phase: 'play',
+ ready: 2,
+ usedBrander: [0, 0],
+ config: tutorialConfig,
+ };
+}
+
+// Step 1: place a cruiser
+const stepPlacement = (() => {
+ const s = baseState();
+ s.phase = 'place';
+ s.ready = 1; // opponent already ready
+ s.cells[0][1] = { type: 'Kr', player: 0, state: {}, label: {} } as any;
+ return s;
+})();
+
+// Step 2: move a cruiser
+const stepMove = (() => {
+ const s = baseState();
+ s.cells[1][1] = { type: 'Kr', player: 0, state: {}, label: {} } as any;
+ return s;
+})();
+
+// Step 3: move a dependent ship (torpedo with boat)
+const stepDependent = (() => {
+ const s = baseState();
+ s.cells[1][1] = { type: 'Tk', player: 0, state: {}, label: {} } as any;
+ s.cells[1][2] = { type: 'T', player: 0, state: {}, label: {} } as any;
+ return s;
+})();
+
+// Step 4: basic attack
+const stepAttack = (() => {
+ const s = baseState();
+ s.cells[2][1] = { type: 'Kr', player: 0, state: {}, label: {} } as any;
+ s.cells[3][1] = { type: 'St', player: 1, state: {}, label: {} } as any;
+ return s;
+})();
+
+// Step 5: attack using a block
+const stepAttackBlock = (() => {
+ const s = baseState();
+ s.cells[2][1] = { type: 'Kr', player: 0, state: {}, label: {} } as any;
+ s.cells[2][2] = { type: 'Kr', player: 0, state: {}, label: {} } as any;
+ s.cells[3][1] = { type: 'Lk', player: 1, state: {}, label: {} } as any;
+ return s;
+})();
+
+// Step 6: shooting from a plane
+const stepShoot = (() => {
+ const s = baseState();
+ s.cells[1][2] = { type: 'Av', player: 0, state: {}, label: {} } as any;
+ s.cells[1][3] = { type: 'Sm', player: 0, state: {}, label: {} } as any;
+ s.cells[3][3] = { type: 'St', player: 1, state: {}, label: {} } as any;
+ return s;
+})();
+
+// Step 7: explode a bomb
+const stepExplode = (() => {
+ const s = baseState();
+ s.cells[2][2] = { type: 'AB', player: 0, state: {}, label: {} } as any;
+ s.cells[3][2] = { type: 'St', player: 1, state: {}, label: {} } as any;
+ return s;
+})();
+
+// Step 8: skip turn
+const stepSkip = (() => {
+ const s = baseState();
+ s.cells[0][1] = { type: 'Kr', player: 0, state: {}, label: {} } as any;
+ s.cells[4][3] = { type: 'St', player: 1, state: {}, label: {} } as any;
+ return s;
+})();
+
+export const tutorialSteps: TutorialStep[] = [
+ {
+ description:
+ 'Размещение кораблей происходит только в первых рядах перед вашим фортом. ' +
+ 'Перетащите крейсер на отмеченную клетку. В реальной игре после расстановки следует нажать «Готов».',
+ state: deepcopy(stepPlacement),
+ moves: [
+ {
+ type: 'move',
+ from: [0, 1],
+ to: [1, 1],
+ mode: 'm',
+ expectedHighlight: [
+ [[0, 1], 'rgba(59, 130, 246, 0.4)'], // source cell - blue
+ [[1, 1], 'rgba(34, 197, 94, 0.4)'], // target cell - green
+ ],
+ expectedHighlightClasses: [
+ [[0, 1], 'tutorial-highlight-source'], // source cell - pulsing blue
+ [[1, 1], 'tutorial-highlight-target'], // target cell - pulsing green
+ ],
+ },
+ ],
+ },
+ {
+ description:
+ 'Большинство кораблей ходит на одну клетку по горизонтали или вертикали. ' +
+ 'Передвиньте крейсер вперёд.',
+ state: deepcopy(stepMove),
+ moves: [
+ {
+ type: 'move',
+ from: [1, 1],
+ to: [2, 1],
+ mode: 'm',
+ expectedHighlight: [
+ [[1, 1], 'rgba(59, 130, 246, 0.4)'], // source cell
+ [[2, 1], 'rgba(34, 197, 94, 0.4)'], // target cell
+ ],
+ expectedHighlightClasses: [
+ [[1, 1], 'tutorial-highlight-source'], // source cell - pulsing blue
+ [[2, 1], 'tutorial-highlight-target'], // target cell - pulsing green
+ ],
+ },
+ ],
+ },
+ {
+ description:
+ 'Некоторые корабли зависят от покровителей. Торпеда может двигаться только оставаясь рядом с катером. ' +
+ 'Сделайте ход торпедой, сохраняя соседство.',
+ state: deepcopy(stepDependent),
+ moves: [
+ {
+ type: 'move',
+ from: [1, 2],
+ to: [2, 2],
+ mode: 'm',
+ expectedHighlight: [
+ [[1, 2], 'rgba(59, 130, 246, 0.4)'], // source cell
+ [[2, 2], 'rgba(34, 197, 94, 0.4)'], // target cell
+ ],
+ expectedHighlightClasses: [
+ [[1, 2], 'tutorial-highlight-source'], // source cell - pulsing blue
+ [[2, 2], 'tutorial-highlight-target'], // target cell - pulsing green
+ ],
+ },
+ ],
+ },
+ {
+ description:
+ 'Чтобы атаковать, перетащите корабль на вражескую клетку и выберите действие ⚔️. ' +
+ 'Попробуйте уничтожить сторожевой корабль.',
+ state: deepcopy(stepAttack),
+ moves: [
+ {
+ type: 'move',
+ from: [2, 1],
+ to: [3, 1],
+ mode: 'a',
+ expectedHighlight: [
+ [[2, 1], 'rgba(59, 130, 246, 0.4)'], // source cell
+ [[3, 1], 'rgba(239, 68, 68, 0.4)'], // attack target - red
+ ],
+ expectedHighlightClasses: [
+ [[2, 1], 'tutorial-highlight-source'], // source cell - pulsing blue
+ [[3, 1], 'tutorial-highlight-attack'], // attack target - pulsing red
+ ],
+ },
+ ],
+ },
+ {
+ description:
+ 'Несколько одинаковых кораблей могут объединиться в блок и атаковать одной силой. ' +
+ 'Сначала атакуйте линкор крейсером, затем выберите блок из двух крейсеров.',
+ state: deepcopy(stepAttackBlock),
+ moves: [
+ {
+ type: 'move',
+ from: [2, 1],
+ to: [3, 1],
+ mode: 'a',
+ expectedHighlight: [
+ [[2, 1], 'rgba(59, 130, 246, 0.4)'], // source cell
+ [[3, 1], 'rgba(239, 68, 68, 0.4)'], // attack target - red
+ ],
+ expectedHighlightClasses: [
+ [[2, 1], 'tutorial-highlight-source'], // source cell - pulsing blue
+ [[3, 1], 'tutorial-highlight-attack'], // attack target - pulsing red
+ ],
+ },
+ {
+ type: 'block',
+ coords: [
+ [2, 1],
+ [2, 2],
+ ],
+ expectedHighlight: [
+ [[2, 1], 'rgba(168, 85, 247, 0.4)'], // block cells - purple
+ [[2, 2], 'rgba(168, 85, 247, 0.4)'],
+ ],
+ expectedHighlightClasses: [
+ [[2, 1], 'tutorial-highlight-block'], // block cells - pulsing purple
+ [[2, 2], 'tutorial-highlight-block'],
+ ],
+ },
+ ],
+ },
+ {
+ description:
+ 'Самолёт стреляет по прямой на любое расстояние, пока рядом находится авианосец. ' +
+ 'Перетащите самолёт на цель и выберите действие выстрела.',
+ state: deepcopy(stepShoot),
+ moves: [
+ {
+ type: 'move',
+ from: [1, 3],
+ to: [3, 3],
+ mode: 's',
+ expectedHighlight: [
+ [[1, 3], 'rgba(59, 130, 246, 0.4)'], // source cell
+ [[3, 3], 'rgba(239, 68, 68, 0.4)'], // shoot target - red
+ ],
+ expectedHighlightClasses: [
+ [[1, 3], 'tutorial-highlight-source'], // source cell - pulsing blue
+ [[3, 3], 'tutorial-highlight-attack'], // shoot target - pulsing red
+ ],
+ },
+ ],
+ },
+ {
+ description: 'Атомная бомба уничтожает всё вокруг себя. Активируйте её прямо в текущей клетке.',
+ state: deepcopy(stepExplode),
+ moves: [
+ {
+ type: 'move',
+ from: [2, 2],
+ to: [2, 2],
+ mode: 'e',
+ expectedHighlight: [
+ [[2, 2], 'rgba(239, 68, 68, 0.6)'], // explode cell - bright red
+ ],
+ expectedHighlightClasses: [
+ [[2, 2], 'tutorial-highlight-attack'], // explode cell - pulsing red
+ ],
+ },
+ ],
+ },
+ {
+ description:
+ 'Если в фазе атаки нет выгодных ходов, можно пропустить атаку. ' +
+ 'Кнопка «Пропустить ход» доступна только в фазе атаки, после совершения хода. ' +
+ 'Нажмите её, чтобы завершить урок.',
+ state: deepcopy(stepSkip),
+ moves: [
+ {
+ type: 'skip',
+ expectedHighlight: [], // No specific cell highlighting for skip
+ expectedHighlightClasses: [], // No CSS classes for skip
+ },
+ ],
+ },
+];