Compare commits
23 Commits
v0.2.4
...
multi-game
| Author | SHA1 | Date | |
|---|---|---|---|
| 51b051b34c | |||
| eb6749dc0b | |||
| ee31b010ac | |||
| 81a54aa93e | |||
| fe1cacb5ed | |||
| 2b0af9fd1f | |||
| 7b677653a7 | |||
| 5c75541c25 | |||
| 83ae342499 | |||
| cc1f45457c | |||
| df0f502191 | |||
| 16355d8028 | |||
| 7671e9b2cc | |||
| fa02e8b4e4 | |||
| fc29111fe1 | |||
| 5b49e5d584 | |||
| 55aac72bd2 | |||
| b10639316e | |||
| d782832fc5 | |||
| b25cd1a039 | |||
| e269dfc208 | |||
| 066f8fbea5 | |||
| 4a3daf7d8c |
@@ -70,6 +70,8 @@ steps:
|
||||
from_secret: WS_PORT
|
||||
WS_SKEY:
|
||||
from_secret: WS_SKEY
|
||||
WS_SSL:
|
||||
from_secret: WS_SSL
|
||||
volumes:
|
||||
- name: dockersock
|
||||
path: /var/run/docker.sock
|
||||
@@ -82,6 +84,7 @@ steps:
|
||||
--build-arg VITE_WS_HOST="$WS_HOST" \
|
||||
--build-arg VITE_WS_PORT="$WS_PORT" \
|
||||
--build-arg VITE_WS_SKEY="$WS_SKEY" \
|
||||
--build-arg VITE_WS_SSL="$WS_SSL" \
|
||||
-t lila-games/tic-tac-toe-ui:$IMAGE_TAG \
|
||||
-t lila-games/tic-tac-toe-ui:latest \
|
||||
/drone/src
|
||||
|
||||
@@ -17,11 +17,12 @@ COPY . .
|
||||
ARG VITE_WS_HOST
|
||||
ARG VITE_WS_PORT
|
||||
ARG VITE_WS_SKEY
|
||||
ARG VITE_WS_SSL
|
||||
|
||||
# Export them as actual environment variables (Vite needs ENV)
|
||||
ENV VITE_WS_HOST=${VITE_WS_HOST}
|
||||
ENV VITE_WS_PORT=${VITE_WS_PORT}
|
||||
ENV VITE_WS_SKEY=${VITE_WS_SKEY}
|
||||
ENV VITE_WS_SSL=${VITE_WS_SSL}
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
230
README.md
230
README.md
@@ -1,41 +1,225 @@
|
||||
# Material UI - React Router example in TypeScript
|
||||
# tic-tac-toe-ui — Multiplayer Game Client (React + TypeScript + Vite)
|
||||
|
||||
## How to use
|
||||
A fully functional multiplayer Tic-Tac-Toe game client built using **React + TypeScript**, powered by **Nakama WebSocket real-time networking**, and delivered as a tiny **production-optimized Vite build** (served via BusyBox/Docker).
|
||||
|
||||
Download the example [or clone the repo](https://github.com/mui/material-ui):
|
||||
This UI communicates with the authoritative backend (`tic-tac-toe`) to deliver a secure, synced, cheat-proof multiplayer experience.
|
||||
|
||||
<!-- #target-branch-reference -->
|
||||
---
|
||||
|
||||
```bash
|
||||
curl https://codeload.github.com/mui/material-ui/tar.gz/master | tar -xz --strip=2 material-ui-master/examples/material-ui-react-router-ts
|
||||
cd material-ui-react-router-ts
|
||||
## 🎮 Overview
|
||||
|
||||
This repository contains the front-end implementation of the Tic-Tac-Toe multiplayer platform.
|
||||
|
||||
The client supports:
|
||||
|
||||
* Device-based authentication
|
||||
* Full matchmaking lifecycle
|
||||
* Real-time gameplay with WebSockets
|
||||
* Authoritative state rendering
|
||||
* Leaderboard browsing
|
||||
* Game result screens
|
||||
* A Vite-powered environment system for dynamic host/SSL selection
|
||||
|
||||
This UI is production-ready and deployable to any server or container environment.
|
||||
|
||||
---
|
||||
|
||||
## ⭐ Features
|
||||
|
||||
* **React + TypeScript UI**
|
||||
* **WebSocket real-time gameplay** using Nakama JS
|
||||
* **Matchmaking flow:** queue → ticket → match → gameplay
|
||||
* **Authoritative state updates** (OpCode 2)
|
||||
* **Secure device authentication** (device UUID → session)
|
||||
* **Leaderboard view** over Nakama's leaderboard API
|
||||
* **Production Docker image:** Node → Vite → BusyBox
|
||||
* **Environment-based configuration** for host/SSL
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Architecture
|
||||
|
||||
### Frontend System Diagram
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
User[Browser] --> UI[React + TS + Vite]
|
||||
UI -->|WebSocket| Nakama
|
||||
UI -->|HTTP| Nakama
|
||||
UI --> Leaderboard[Leaderboard API]
|
||||
UI --> Matchmaking[Matchmaker API]
|
||||
```
|
||||
|
||||
Install it and run:
|
||||
---
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
## 🛠 Tech Stack
|
||||
|
||||
* **React 18** (TypeScript)
|
||||
* **Vite.js** (build system)
|
||||
* **Nakama JavaScript Client**
|
||||
* **Plain CSS** for styling
|
||||
* **WebSockets (SSL / non-SSL selectable)**
|
||||
* **Docker (multi-stage build)**
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 🔧 Environment Variables (Vite)
|
||||
|
||||
These are injected at build time:
|
||||
|
||||
```
|
||||
VITE_WS_HOST=nakama.aetoskia.com
|
||||
VITE_WS_PORT=443
|
||||
VITE_WS_SKEY=secret
|
||||
VITE_WS_SSL=true
|
||||
```
|
||||
|
||||
or:
|
||||
Meaning:
|
||||
|
||||
<!-- #target-branch-reference -->
|
||||
* **VITE_WS_HOST** → Nakama host (domain or IP)
|
||||
* **VITE_WS_PORT** → Port for WebSocket/API
|
||||
* **VITE_WS_SKEY** → Nakama server key
|
||||
* **VITE_WS_SSL** → `true` for wss://, `false` for ws://
|
||||
|
||||
[](https://codesandbox.io/p/sandbox/github/mui/material-ui/tree/master/examples/material-ui-react-router-ts)
|
||||
---
|
||||
|
||||
[](https://stackblitz.com/github/mui/material-ui/tree/master/examples/material-ui-react-router-ts)
|
||||
## 🔌 Runtime Flow
|
||||
|
||||
## The idea behind the example
|
||||
### Authentication
|
||||
|
||||
<!-- #host-reference -->
|
||||
* UI generates a device UUID
|
||||
* Calls `client.authenticateDevice()`
|
||||
* Stores session in React state
|
||||
|
||||
This example demonstrates how you can use Material UI with [React Router](https://reactrouter.com/) in [TypeScript](https://github.com/Microsoft/TypeScript).
|
||||
It includes `@mui/material` and its peer dependencies, including [Emotion](https://emotion.sh/docs/introduction), the default style engine in Material UI.
|
||||
### Matchmaking
|
||||
|
||||
## What's next?
|
||||
1. User selects mode (classic / blitz)
|
||||
2. joins the matchmaking queue
|
||||
3. Waits for matchmaker ticket
|
||||
4. Auto-joins the match when assigned
|
||||
|
||||
<!-- #host-reference -->
|
||||
### Gameplay
|
||||
|
||||
You now have a working example project.
|
||||
You can head back to the documentation and continue by browsing the [templates](https://mui.com/material-ui/getting-started/templates/) section.
|
||||
* User sends moves via OpCode **1**
|
||||
* Server validates + broadcasts authoritative board via OpCode **2**
|
||||
* UI re-renders board state from server packets
|
||||
|
||||
### End of Game
|
||||
|
||||
* Player sees win/lose/draw
|
||||
* Can return to home or matchmaking
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Styling
|
||||
|
||||
Styling uses **plain CSS** via a single `styles.css` file.
|
||||
Simple, responsive layout using Flexbox.
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker (Production Build)
|
||||
|
||||
### Dockerfile Overview
|
||||
|
||||
```
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package.json and package-lock.json (or yarn.lock)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci
|
||||
|
||||
# Copy the rest of the app
|
||||
COPY . .
|
||||
|
||||
# Build arguments
|
||||
ARG VITE_WS_HOST
|
||||
ARG VITE_WS_PORT
|
||||
ARG VITE_WS_SKEY
|
||||
ARG VITE_WS_SSL
|
||||
|
||||
# Export them as actual environment variables (Vite needs ENV)
|
||||
ENV VITE_WS_HOST=${VITE_WS_HOST}
|
||||
ENV VITE_WS_PORT=${VITE_WS_PORT}
|
||||
ENV VITE_WS_SSL=${VITE_WS_SSL}
|
||||
|
||||
# Build
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Static file server (BusyBox)
|
||||
FROM busybox:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy only build frontend files
|
||||
COPY --from=builder /app/dist /app
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command
|
||||
CMD ["busybox", "httpd", "-f", "-p", "3000"]
|
||||
```
|
||||
|
||||
Produces an extremely lightweight production image.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing
|
||||
|
||||
Manual testing validated:
|
||||
|
||||
* Full matchmaking loop
|
||||
* Game state sync
|
||||
* Invalid move handling (server rejections)
|
||||
* Disconnect behaviour
|
||||
* Leaderboard retrieval
|
||||
|
||||
Pending:
|
||||
|
||||
* Stress tests
|
||||
* Mobile responsiveness
|
||||
* Reconnect logic
|
||||
|
||||
---
|
||||
|
||||
## 📈 Deployment
|
||||
|
||||
### Supported:
|
||||
|
||||
* Docker on any Linux host
|
||||
* Raspberry Pi (ARM)
|
||||
* Google Cloud Run / Compute Engine
|
||||
* Traefik reverse proxy via `games.aetoskia.com`
|
||||
|
||||
### Example Deployment via Docker
|
||||
|
||||
```
|
||||
docker run -d \
|
||||
-p 3003:3003 \
|
||||
--restart always \
|
||||
tic-tac-toe-ui:latest
|
||||
```
|
||||
|
||||
Traefik HTTPS routes:
|
||||
|
||||
* **games.aetoskia.com** → UI
|
||||
|
||||
---
|
||||
|
||||
## 🗺️ Roadmap
|
||||
|
||||
* Rematch flow
|
||||
* Reconnect/resume after refresh
|
||||
* Improved animations
|
||||
* Mobile UI redesign
|
||||
* Centralized error handling
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tictactoe-vite",
|
||||
"version": "v0.2.2",
|
||||
"version": "v1.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,21 +1,57 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
import Board from "./Board";
|
||||
import Player from "./Player";
|
||||
import { PlayerModel } from "./models/player";
|
||||
|
||||
export default function TicTacToe() {
|
||||
const [board, setBoard] = useState<string[][]>([
|
||||
["", "", ""],
|
||||
["", "", ""],
|
||||
["", "", ""]
|
||||
]);
|
||||
import TicTacToeBoard from "./games/tictactoe/TicTacToeBoard";
|
||||
import BattleShipBoard from "./games/battleship/BattleShipBoard";
|
||||
|
||||
export default function App() {
|
||||
// setting up a 2D game boards
|
||||
const [boards, setBoards] = useState<Record<string, { grid: string[][] }>>({});
|
||||
const [turn, setTurn] = useState<number>(0);
|
||||
const [winner, setWinner] = useState<string | null>(null);
|
||||
const [players, setPlayers] = useState<string[]>([]);
|
||||
const [gameOver, setGameOver] = useState<boolean | null>(null);
|
||||
const [players, setPlayers] = useState<PlayerModel[]>([]);
|
||||
const [metadata, setMetadata] = useState<Record<string, any>>({});
|
||||
|
||||
const { sendMatchData, onMatchData, matchId, session } = useNakama();
|
||||
|
||||
function renderGameBoard() {
|
||||
if (!matchId || !metadata?.game) return null;
|
||||
|
||||
switch (metadata.game) {
|
||||
case "tictactoe":
|
||||
return (
|
||||
<TicTacToeBoard
|
||||
boards={boards}
|
||||
turn={turn}
|
||||
winner={winner}
|
||||
gameOver={gameOver}
|
||||
players={players}
|
||||
myUserId={session?.user_id ?? null}
|
||||
onCellClick={handleCellClick}
|
||||
/>
|
||||
);
|
||||
|
||||
case "battleship":
|
||||
return (
|
||||
<BattleShipBoard
|
||||
boards={boards}
|
||||
turn={turn}
|
||||
winner={winner}
|
||||
gameOver={gameOver}
|
||||
players={players}
|
||||
myUserId={session?.user_id ?? null}
|
||||
metadata={metadata}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <div>Unknown game: {metadata.game}</div>;
|
||||
}
|
||||
}
|
||||
// ------------------------------------------
|
||||
// MATCH DATA CALLBACK (from Player component)
|
||||
// ------------------------------------------
|
||||
@@ -26,10 +62,20 @@ export default function TicTacToe() {
|
||||
const state = msg.data;
|
||||
console.log("Match state:", state);
|
||||
|
||||
setBoard(state.board);
|
||||
setBoards(state.boards);
|
||||
setTurn(state.turn);
|
||||
setWinner(state.winner || null);
|
||||
setGameOver(state.game_over);
|
||||
if (state.winner >= 0) {
|
||||
setWinner(state.players[state.winner].username);
|
||||
// } else if (state.game_over) {
|
||||
// // Game ended but winner = -1 → draw
|
||||
// setWinner("draw");
|
||||
} else {
|
||||
// Ongoing game, no winner
|
||||
setWinner(null);
|
||||
}
|
||||
setPlayers(state.players || []);
|
||||
setMetadata(state.metadata || {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +96,7 @@ export default function TicTacToe() {
|
||||
function handleCellClick(row: number, col: number) {
|
||||
if (!matchId) return;
|
||||
|
||||
sendMatchData(matchId, 1, { row, col }); // OpMove=1
|
||||
sendMatchData(matchId, 1, {data: {row, col}});
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -77,7 +123,7 @@ export default function TicTacToe() {
|
||||
letterSpacing: "1px",
|
||||
}}
|
||||
>
|
||||
Tic Tac Toe
|
||||
Games
|
||||
</header>
|
||||
|
||||
{/* ---------------- MAIN CONTENT (scrolls) ---------------- */}
|
||||
@@ -104,14 +150,7 @@ export default function TicTacToe() {
|
||||
minWidth: "300px",
|
||||
}}
|
||||
>
|
||||
<Board
|
||||
board={board}
|
||||
turn={turn}
|
||||
winner={winner}
|
||||
players={players}
|
||||
myUserId={session?.user_id ?? null}
|
||||
onCellClick={handleCellClick}
|
||||
/>
|
||||
{renderGameBoard()}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import Leaderboard from "./Leaderboard";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
|
||||
export default function Player({
|
||||
@@ -15,11 +14,13 @@ export default function Player({
|
||||
logout,
|
||||
onMatchData,
|
||||
joinMatchmaker,
|
||||
exitMatchmaker,
|
||||
} = useNakama();
|
||||
|
||||
const [username, setUsername] = useState(
|
||||
localStorage.getItem("username") ?? ""
|
||||
);
|
||||
const [selectedGame, setSelectedGame] = useState("tictactoe");
|
||||
const [selectedMode, setSelectedMode] = useState("classic");
|
||||
const [isQueueing, setIsQueueing] = useState(false);
|
||||
const isRegistered = localStorage.getItem("registered") === "yes";
|
||||
@@ -37,11 +38,18 @@ export default function Player({
|
||||
// ------------------------------------------
|
||||
// MATCHMAKING
|
||||
// ------------------------------------------
|
||||
async function startQueue(selectedMode: string) {
|
||||
async function startQueue(
|
||||
selectedGame: string,
|
||||
selectedMode: string
|
||||
) {
|
||||
setIsQueueing(true);
|
||||
const gameMetadata = {
|
||||
game: selectedGame,
|
||||
mode: selectedMode,
|
||||
}
|
||||
|
||||
try {
|
||||
const ticket = await joinMatchmaker(selectedMode);
|
||||
const ticket = await joinMatchmaker(gameMetadata);
|
||||
console.log("Queued:", ticket);
|
||||
} catch (err) {
|
||||
console.error("Matchmaking failed:", err);
|
||||
@@ -49,10 +57,17 @@ export default function Player({
|
||||
}
|
||||
}
|
||||
|
||||
function cancelQueue() {
|
||||
async function cancelQueue(
|
||||
selectedGame: string,
|
||||
selectedMode: string
|
||||
) {
|
||||
setIsQueueing(false);
|
||||
// Nakama matchmaker tickets auto-expire by default in your setup.
|
||||
// If you later add manual ticket cancel RPC, call it here.
|
||||
|
||||
const gameMetadata = {
|
||||
game: selectedGame,
|
||||
mode: selectedMode,
|
||||
}
|
||||
await exitMatchmaker(gameMetadata)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -147,9 +162,9 @@ export default function Player({
|
||||
</label>
|
||||
|
||||
<select
|
||||
value={selectedMode}
|
||||
value={selectedGame}
|
||||
disabled={isQueueing}
|
||||
onChange={(e) => setSelectedMode(e.target.value)}
|
||||
onChange={(e) => setSelectedGame(e.target.value)}
|
||||
style={{
|
||||
padding: "8px",
|
||||
margin: "10px 0 16px",
|
||||
@@ -160,14 +175,35 @@ export default function Player({
|
||||
border: "1px solid #333",
|
||||
}}
|
||||
>
|
||||
<option value="classic">Classic</option>
|
||||
<option value="blitz">Blitz</option>
|
||||
<option value="tictactoe">Tic Tac Toe</option>
|
||||
<option value="battleship">Battleship</option>
|
||||
</select>
|
||||
|
||||
{/*<select*/}
|
||||
{/* value={selectedMode}*/}
|
||||
{/* disabled={isQueueing}*/}
|
||||
{/* onChange={(e) => setSelectedMode(e.target.value)}*/}
|
||||
{/* style={{*/}
|
||||
{/* padding: "8px",*/}
|
||||
{/* margin: "10px 0 16px",*/}
|
||||
{/* width: "60%",*/}
|
||||
{/* borderRadius: "10px",*/}
|
||||
{/* background: "#222",*/}
|
||||
{/* color: "white",*/}
|
||||
{/* border: "1px solid #333",*/}
|
||||
{/* }}*/}
|
||||
{/*>*/}
|
||||
{/* <option value="classic">Classic</option>*/}
|
||||
{/* <option value="blitz">Blitz</option>*/}
|
||||
{/*</select>*/}
|
||||
|
||||
{!isQueueing && (
|
||||
<motion.button
|
||||
whileTap={{ scale: 0.95 }}
|
||||
onClick={() => startQueue(selectedMode)}
|
||||
onClick={() => startQueue(
|
||||
selectedGame,
|
||||
selectedMode,
|
||||
)}
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
borderRadius: "12px",
|
||||
@@ -216,7 +252,10 @@ export default function Player({
|
||||
|
||||
{/* Cancel button */}
|
||||
<button
|
||||
onClick={cancelQueue}
|
||||
onClick={() => cancelQueue(
|
||||
selectedGame,
|
||||
selectedMode,
|
||||
)}
|
||||
style={{
|
||||
marginTop: "10px",
|
||||
padding: "6px 12px",
|
||||
@@ -235,7 +274,6 @@ export default function Player({
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: "24px" }}>
|
||||
<Leaderboard />
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
147
src/games/battleship/BattleShipBoard.tsx
Normal file
147
src/games/battleship/BattleShipBoard.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useNakama } from "../../providers/NakamaProvider";
|
||||
import { PlayerModel } from "../../models/player";
|
||||
|
||||
import PlacementGrid from "./placement/PlacementGrid";
|
||||
import ShotGrid from "./battle/ShotGrid";
|
||||
|
||||
interface BattleBoardProps {
|
||||
boards: Record<string, { grid: string[][] }>;
|
||||
players: PlayerModel[];
|
||||
myUserId: string | null;
|
||||
turn: number;
|
||||
winner: string | null;
|
||||
gameOver: boolean | null;
|
||||
metadata: Record<string, any>;
|
||||
}
|
||||
|
||||
const Fleet: Record<string, number> = {
|
||||
carrier: 5,
|
||||
battleship: 4,
|
||||
cruiser: 3,
|
||||
submarine: 3,
|
||||
destroyer: 2,
|
||||
};
|
||||
const FLEET_ORDER = ["carrier", "battleship", "cruiser", "submarine", "destroyer"];
|
||||
|
||||
export default function BattleShipBoard({
|
||||
boards,
|
||||
players,
|
||||
myUserId,
|
||||
turn,
|
||||
winner,
|
||||
gameOver,
|
||||
metadata,
|
||||
}: BattleBoardProps) {
|
||||
const { sendMatchData, matchId } = useNakama();
|
||||
|
||||
const myIndex = players.findIndex((p) => p.user_id === myUserId);
|
||||
const oppIndex = myIndex === 0 ? 1 : 0;
|
||||
|
||||
const phase = metadata["phase"] ?? "lobby";
|
||||
const isMyTurn = phase === "battle" && turn === myIndex;
|
||||
|
||||
const myShips = boards[`p${myIndex}_ships`]?.grid ?? [[]];
|
||||
const myShots = boards[`p${myIndex}_shots`] ?.grid ?? [[]];
|
||||
|
||||
const placed = metadata[`p${myIndex}_placed`] ?? 0;
|
||||
|
||||
const nextShip = FLEET_ORDER[placed] || null;
|
||||
const nextShipSize = nextShip ? Fleet[nextShip] : null;
|
||||
|
||||
// ------------------- PLACE SHIP -------------------
|
||||
function handlePlace(ship: string, r: number, c: number, dir: "h" | "v") {
|
||||
sendMatchData(matchId!, 1, {
|
||||
action: "place",
|
||||
data: {
|
||||
ship: ship,
|
||||
row: r,
|
||||
col: c,
|
||||
dir,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------- SHOOT -------------------
|
||||
function handleShoot(r: number, c: number) {
|
||||
sendMatchData(matchId!, 1, {
|
||||
action: "shoot",
|
||||
data: {
|
||||
row: r,
|
||||
col: c,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------- STATUS LABEL -------------------
|
||||
const status = useMemo(() => {
|
||||
if (phase === "lobby") return `In Lobby`;
|
||||
if (winner !== null) return `Winner: Player ${winner}`;
|
||||
if (gameOver) return "Game over — draw";
|
||||
if (phase === "placement") return `Place your ${nextShip ?? ""}`;
|
||||
if (myIndex === -1) return "Spectating";
|
||||
if (!isMyTurn) return "Opponent’s turn";
|
||||
return "Your turn";
|
||||
}, [winner, gameOver, phase, isMyTurn, myIndex, nextShip]);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<h2 style={{ marginBottom: 8 }}>{status}</h2>
|
||||
|
||||
{/* ---------------- PHASE 1: PLACEMENT ---------------- */}
|
||||
{phase === "placement" && nextShip && (
|
||||
<PlacementGrid
|
||||
shipBoard={myShips}
|
||||
shipName={nextShip}
|
||||
shipSize={nextShipSize}
|
||||
onPlace={handlePlace}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ---------------- PHASE 2: BATTLE ---------------- */}
|
||||
{phase === "battle" && (
|
||||
<>
|
||||
<h3>Your Shots</h3>
|
||||
|
||||
<ShotGrid
|
||||
grid={myShots}
|
||||
isMyTurn={isMyTurn}
|
||||
gameOver={!!gameOver}
|
||||
onShoot={handleShoot}
|
||||
/>
|
||||
|
||||
<h3 style={{ marginTop: "18px" }}>Your Ships</h3>
|
||||
<PlacementGrid
|
||||
shipBoard={myShips}
|
||||
shipName="readonly"
|
||||
shipSize={0}
|
||||
onPlace={() => {}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ---------------- WINNER UI ---------------- */}
|
||||
{winner !== null && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, scale: [1, 1.05, 1] }}
|
||||
transition={{ repeat: Infinity, duration: 1.4 }}
|
||||
style={{
|
||||
marginTop: 12,
|
||||
fontSize: "20px",
|
||||
fontWeight: "bold",
|
||||
color: "#f1c40f",
|
||||
}}
|
||||
>
|
||||
🎉 Player {winner} Wins! 🎉
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
100
src/games/battleship/battle/ShotGrid.tsx
Normal file
100
src/games/battleship/battle/ShotGrid.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface ShotGridProps {
|
||||
grid: string[][]; // your shots: "", "H", "M"
|
||||
isMyTurn: boolean; // only clickable on your turn
|
||||
gameOver: boolean;
|
||||
onShoot: (row: number, col: number) => void;
|
||||
}
|
||||
|
||||
export default function ShotGrid({
|
||||
grid,
|
||||
isMyTurn,
|
||||
gameOver,
|
||||
onShoot,
|
||||
}: ShotGridProps) {
|
||||
const rows = grid.length;
|
||||
const cols = grid[0].length;
|
||||
|
||||
function handleClick(r: number, c: number) {
|
||||
if (!isMyTurn || gameOver) return;
|
||||
if (grid[r][c] !== "") return; // can't shoot twice
|
||||
onShoot(r, c);
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 36px)`,
|
||||
gap: "4px",
|
||||
justifyContent: "center",
|
||||
marginTop: "12px",
|
||||
}}
|
||||
>
|
||||
{grid.map((row, r) =>
|
||||
row.map((cell, c) => {
|
||||
const empty = cell === "";
|
||||
const hit = cell === "H";
|
||||
const miss = cell === "M";
|
||||
|
||||
let bg = "#111";
|
||||
|
||||
if (hit) bg = "#e74c3c"; // red for hit
|
||||
if (miss) bg = "#34495e"; // gray for miss
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={`${r}-${c}`}
|
||||
whileHover={empty && isMyTurn && !gameOver ? { scale: 1.1 } : {}}
|
||||
whileTap={empty && isMyTurn && !gameOver ? { scale: 0.9 } : {}}
|
||||
onClick={() => handleClick(r, c)}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 4,
|
||||
border: "1px solid #444",
|
||||
background: bg,
|
||||
cursor:
|
||||
empty && isMyTurn && !gameOver ? "pointer" : "not-allowed",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: "white",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
<AnimatePresence>
|
||||
{hit && (
|
||||
<motion.span
|
||||
key="hit"
|
||||
initial={{ scale: 0.2, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.2, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 10 }}
|
||||
>
|
||||
H
|
||||
</motion.span>
|
||||
)}
|
||||
|
||||
{miss && (
|
||||
<motion.span
|
||||
key="miss"
|
||||
initial={{ scale: 0.2, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.2, opacity: 0 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 10 }}
|
||||
>
|
||||
M
|
||||
</motion.span>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
87
src/games/battleship/components/ShipSelector.tsx
Normal file
87
src/games/battleship/components/ShipSelector.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface ShipSelectorProps {
|
||||
remainingShips: string[]; // ex: ["carrier", "battleship", ...]
|
||||
selectedShip: string | null;
|
||||
orientation: "h" | "v";
|
||||
onSelectShip: (ship: string) => void;
|
||||
onToggleOrientation: () => void;
|
||||
}
|
||||
|
||||
export default function ShipSelector({
|
||||
remainingShips,
|
||||
selectedShip,
|
||||
orientation,
|
||||
onSelectShip,
|
||||
onToggleOrientation,
|
||||
}: ShipSelectorProps) {
|
||||
return (
|
||||
<div style={{ marginTop: 12, textAlign: "center" }}>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 0.9 }}
|
||||
style={{
|
||||
marginBottom: 10,
|
||||
fontSize: 16,
|
||||
color: "#ddd",
|
||||
}}
|
||||
>
|
||||
Select ship & orientation
|
||||
</motion.div>
|
||||
|
||||
{/* SHIP BUTTONS */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "center",
|
||||
gap: "10px",
|
||||
flexWrap: "wrap",
|
||||
marginBottom: 14,
|
||||
}}
|
||||
>
|
||||
{remainingShips.map((ship) => {
|
||||
const active = ship === selectedShip;
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={ship}
|
||||
onClick={() => onSelectShip(ship)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
borderRadius: 8,
|
||||
background: active ? "#f1c40f" : "#333",
|
||||
color: active ? "#000" : "#fff",
|
||||
border: "2px solid #444",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{ship.toUpperCase()}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* ORIENTATION BUTTON */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
onClick={onToggleOrientation}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
borderRadius: 8,
|
||||
background: "#222",
|
||||
color: "white",
|
||||
border: "2px solid #444",
|
||||
cursor: "pointer",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
Orientation: <strong>{orientation === "h" ? "Horizontal" : "Vertical"}</strong>
|
||||
</motion.button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/games/battleship/components/StatusBar.tsx
Normal file
24
src/games/battleship/components/StatusBar.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface StatusBarProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
export default function StatusBar({ text }: StatusBarProps) {
|
||||
return (
|
||||
<motion.div
|
||||
key={text}
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
style={{
|
||||
marginBottom: 8,
|
||||
fontSize: "18px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
169
src/games/battleship/phases/BattlePhase.tsx
Normal file
169
src/games/battleship/phases/BattlePhase.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import React from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface BattleProps {
|
||||
myIndex: number;
|
||||
boards: Record<string, { grid: string[][] }>;
|
||||
turn: number;
|
||||
winner: string | null;
|
||||
gameOver: boolean | null;
|
||||
players: any[];
|
||||
myUserId: string | null;
|
||||
onShoot: (row: number, col: number) => void;
|
||||
}
|
||||
|
||||
export default function BattlePhase({
|
||||
myIndex,
|
||||
boards,
|
||||
turn,
|
||||
winner,
|
||||
gameOver,
|
||||
players,
|
||||
myUserId,
|
||||
onShoot,
|
||||
}: BattleProps) {
|
||||
if (myIndex < 0) return <div>Spectating...</div>;
|
||||
|
||||
const myShots = boards[`p${myIndex}_shots`]?.grid;
|
||||
const myShips = boards[`p${myIndex}_ships`]?.grid;
|
||||
const isMyTurn = turn === myIndex;
|
||||
const gameReady = players.length === 2;
|
||||
|
||||
if (!myShots || !myShips) return <div>Loading...</div>;
|
||||
|
||||
const renderShotCell = (cell: string, r: number, c: number) => {
|
||||
const disabled =
|
||||
!isMyTurn ||
|
||||
gameOver ||
|
||||
cell === "H" ||
|
||||
cell === "M"; // can't shoot same cell
|
||||
|
||||
const bg =
|
||||
cell === "H"
|
||||
? "#e74c3c" // red = hit
|
||||
: cell === "M"
|
||||
? "#34495e" // blue/gray = miss
|
||||
: "#1c1c1c"; // untouched
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`shot-${r}-${c}`}
|
||||
onClick={() => !disabled && onShoot(r, c)}
|
||||
whileHover={!disabled ? { scale: 1.06 } : {}}
|
||||
whileTap={!disabled ? { scale: 0.85 } : {}}
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
border: "1px solid #333",
|
||||
background: bg,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderShipCell = (cell: string, r: number, c: number) => {
|
||||
const bg =
|
||||
cell === "S"
|
||||
? "#2980b9" // ship
|
||||
: cell === "X"
|
||||
? "#c0392b" // destroyed
|
||||
: "#111"; // water
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`ship-${r}-${c}`}
|
||||
style={{
|
||||
width: 38,
|
||||
height: 38,
|
||||
border: "1px solid #222",
|
||||
background: bg,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<h2 style={{ marginBottom: 10 }}>
|
||||
{gameOver
|
||||
? winner === myUserId
|
||||
? "🎉 Victory! 🎉"
|
||||
: "💥 Defeat 💥"
|
||||
: isMyTurn
|
||||
? "Your Turn — Fire!"
|
||||
: "Opponent's Turn"}
|
||||
</h2>
|
||||
|
||||
{/* -------------------------
|
||||
TOP SECTION — YOUR SHOTS
|
||||
-------------------------- */}
|
||||
<div style={{ marginBottom: 12, opacity: 0.85 }}>
|
||||
<strong>Your Shots</strong>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${myShots[0].length}, 38px)`,
|
||||
gap: 4,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{myShots.map((row, r) =>
|
||||
row.map((cell, c) => renderShotCell(cell, r, c))
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* -------------------------
|
||||
BOTTOM SECTION — YOUR FLEET
|
||||
-------------------------- */}
|
||||
<div style={{ marginTop: 28, marginBottom: 12, opacity: 0.85 }}>
|
||||
<strong>Your Fleet Status</strong>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${myShips[0].length}, 38px)`,
|
||||
gap: 4,
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{myShips.map((row, r) =>
|
||||
row.map((cell, c) => renderShipCell(cell, r, c))
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{winner && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: [1, 1.06, 1],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
duration: 1.4,
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
style={{
|
||||
color: "#f1c40f",
|
||||
fontSize: "20px",
|
||||
marginTop: "18px",
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{winner === players[myIndex].user_id
|
||||
? "🎉 You Win! 🎉"
|
||||
: "💥 You Lost 💥"}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
100
src/games/battleship/phases/BattleStatus.tsx
Normal file
100
src/games/battleship/phases/BattleStatus.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface BattleStatusProps {
|
||||
phase: string;
|
||||
myIndex: number;
|
||||
turn: number;
|
||||
players: any[];
|
||||
lastHit: boolean | null;
|
||||
winner: string | null;
|
||||
gameOver: boolean | null;
|
||||
}
|
||||
|
||||
export default function BattleStatus({
|
||||
phase,
|
||||
myIndex,
|
||||
turn,
|
||||
players,
|
||||
lastHit,
|
||||
winner,
|
||||
gameOver,
|
||||
}: BattleStatusProps) {
|
||||
const isMyTurn = turn === myIndex;
|
||||
|
||||
// -----------------------------
|
||||
// STATUS TEXT
|
||||
// -----------------------------
|
||||
let statusText = "";
|
||||
|
||||
if (gameOver) {
|
||||
statusText =
|
||||
winner === players[myIndex]?.user_id ? "🎉 Victory!" : "💥 Defeat";
|
||||
} else if (phase === "placement") {
|
||||
statusText = "Place Your Fleet";
|
||||
} else {
|
||||
statusText = isMyTurn ? "Your Turn — FIRE!" : "Opponent’s Turn";
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Last hit/miss indicator
|
||||
// -----------------------------
|
||||
let hitText = null;
|
||||
if (lastHit === true) hitText = "🔥 HIT!";
|
||||
else if (lastHit === false) hitText = "💦 MISS";
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{
|
||||
textAlign: "center",
|
||||
marginBottom: 14,
|
||||
color: "#eee",
|
||||
fontFamily: "sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* MAIN STATUS */}
|
||||
<motion.div
|
||||
key={statusText}
|
||||
initial={{ opacity: 0, y: -4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
style={{
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{statusText}
|
||||
</motion.div>
|
||||
|
||||
{/* HIT / MISS FEEDBACK */}
|
||||
<AnimatePresence mode="wait">
|
||||
{hitText && !gameOver && (
|
||||
<motion.div
|
||||
key={hitText}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.7 }}
|
||||
transition={{ duration: 0.25 }}
|
||||
style={{
|
||||
color: lastHit ? "#e74c3c" : "#3498db",
|
||||
fontSize: 18,
|
||||
fontWeight: 600,
|
||||
marginTop: 4,
|
||||
}}
|
||||
>
|
||||
{hitText}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* PHASE */}
|
||||
<div style={{ marginTop: 6, opacity: 0.55, fontSize: 14 }}>
|
||||
Phase: <strong>{phase}</strong>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
145
src/games/battleship/phases/PlacementPhase.tsx
Normal file
145
src/games/battleship/phases/PlacementPhase.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
const fleet = [
|
||||
{ name: "carrier", size: 5 },
|
||||
{ name: "battleship", size: 4 },
|
||||
{ name: "cruiser", size: 3 },
|
||||
{ name: "submarine", size: 3 },
|
||||
{ name: "destroyer", size: 2 },
|
||||
];
|
||||
|
||||
interface PlacementProps {
|
||||
myIndex: number;
|
||||
boards: Record<string, { grid: string[][] }>;
|
||||
metadata: Record<string, any>;
|
||||
onPlace: (ship: string, row: number, col: number, dir: string) => void;
|
||||
players: any[];
|
||||
}
|
||||
|
||||
export default function PlacementPhase({
|
||||
myIndex,
|
||||
boards,
|
||||
metadata,
|
||||
onPlace,
|
||||
}: PlacementProps) {
|
||||
const shipBoard = boards[`p${myIndex}_ships`]?.grid;
|
||||
const placedCount = metadata[`p${myIndex}_placed`] ?? 0;
|
||||
|
||||
const [selectedShip, setSelectedShip] = useState<string | null>(null);
|
||||
const [orientation, setOrientation] = useState<"h" | "v">("h");
|
||||
const [hoverPos, setHoverPos] = useState<[number, number] | null>(null);
|
||||
|
||||
if (!shipBoard) return <div>Loading...</div>;
|
||||
|
||||
const remainingShips = fleet.slice(placedCount);
|
||||
|
||||
const current = remainingShips[0];
|
||||
const shipName = current?.name ?? null;
|
||||
const shipSize = current?.size ?? 0;
|
||||
|
||||
const canPlace = (r: number, c: number) => {
|
||||
if (!shipName) return false;
|
||||
|
||||
// bounds
|
||||
if (orientation === "h") {
|
||||
if (c + shipSize > shipBoard[0].length) return false;
|
||||
for (let i = 0; i < shipSize; i++) {
|
||||
if (shipBoard[r][c + i] !== "") return false;
|
||||
}
|
||||
} else {
|
||||
if (r + shipSize > shipBoard.length) return false;
|
||||
for (let i = 0; i < shipSize; i++) {
|
||||
if (shipBoard[r + i][c] !== "") return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const renderCell = (cell: string, r: number, c: number) => {
|
||||
const hovered = hoverPos?.[0] === r && hoverPos?.[1] === c;
|
||||
const placing = hovered && shipName;
|
||||
|
||||
let previewColor = "transparent";
|
||||
|
||||
if (placing) {
|
||||
const valid = canPlace(r, c);
|
||||
previewColor = valid ? "rgba(46, 204, 113, 0.4)" : "rgba(231, 76, 60, 0.4)";
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${r}-${c}`}
|
||||
onMouseEnter={() => setHoverPos([r, c])}
|
||||
onMouseLeave={() => setHoverPos(null)}
|
||||
onClick={() => {
|
||||
if (shipName && canPlace(r, c)) {
|
||||
onPlace(shipName, r, c, orientation);
|
||||
}
|
||||
}}
|
||||
whileHover={{ scale: 1.03 }}
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
border: "1px solid #333",
|
||||
background: cell === "S" ? "#2980b9" : previewColor,
|
||||
cursor: shipName ? "pointer" : "default",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
style={{ textAlign: "center" }}
|
||||
>
|
||||
<h2 style={{ marginBottom: 10 }}>Place Your Fleet</h2>
|
||||
|
||||
{shipName ? (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<strong>Placing:</strong> {shipName} ({shipSize})
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ marginBottom: 12, color: "#2ecc71" }}>
|
||||
✅ All ships placed — waiting for opponent...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shipName && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<button
|
||||
onClick={() => setOrientation(orientation === "h" ? "v" : "h")}
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
background: "#111",
|
||||
border: "1px solid #666",
|
||||
borderRadius: 6,
|
||||
color: "white",
|
||||
}}
|
||||
>
|
||||
Orientation: {orientation === "h" ? "Horizontal" : "Vertical"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* BOARD GRID */}
|
||||
<motion.div
|
||||
layout
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${shipBoard[0].length}, 40px)`,
|
||||
gap: 4,
|
||||
justifyContent: "center",
|
||||
marginTop: 20,
|
||||
}}
|
||||
>
|
||||
{shipBoard.map((row, r) =>
|
||||
row.map((cell, c) => renderCell(cell, r, c))
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
127
src/games/battleship/placement/PlacementGrid.tsx
Normal file
127
src/games/battleship/placement/PlacementGrid.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
interface ShipGridProps {
|
||||
shipBoard: string[][]; // current placed ships
|
||||
shipName: string | null; // current ship being placed
|
||||
shipSize: number | null;
|
||||
onPlace: (ship: string, row: number, col: number, dir: "h" | "v") => void;
|
||||
}
|
||||
|
||||
export default function PlacementGrid({
|
||||
shipBoard,
|
||||
shipName,
|
||||
shipSize,
|
||||
onPlace,
|
||||
}: ShipGridProps) {
|
||||
const [dir, setDir] = useState<"h" | "v">("h");
|
||||
const [hoverR, setHoverR] = useState<number | null>(null);
|
||||
const [hoverC, setHoverC] = useState<number | null>(null);
|
||||
|
||||
const rows = shipBoard.length;
|
||||
const cols = shipBoard[0].length;
|
||||
|
||||
function isPreviewCell(r: number, c: number): boolean {
|
||||
if (hoverR === null || hoverC === null || !shipSize) return false;
|
||||
|
||||
if (dir === "h") {
|
||||
return r === hoverR && c >= hoverC && c < hoverC + shipSize;
|
||||
} else {
|
||||
return c === hoverC && r >= hoverR && r < hoverR + shipSize;
|
||||
}
|
||||
}
|
||||
|
||||
function isPreviewValid(): boolean {
|
||||
if (hoverR === null || hoverC === null || !shipSize) return false;
|
||||
|
||||
if (dir === "h") {
|
||||
if (hoverC + shipSize > cols) return false;
|
||||
for (let i = 0; i < shipSize; i++) {
|
||||
if (shipBoard[hoverR][hoverC + i] !== "") return false;
|
||||
}
|
||||
} else {
|
||||
if (hoverR + shipSize > rows) return false;
|
||||
for (let i = 0; i < shipSize; i++) {
|
||||
if (shipBoard[hoverR + i][hoverC] !== "") return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (shipName && shipSize && hoverR !== null && hoverC !== null) {
|
||||
if (isPreviewValid()) {
|
||||
onPlace(shipName, hoverR, hoverC, dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
{/* Ship rotation button */}
|
||||
<button
|
||||
onClick={() => setDir(dir === "h" ? "v" : "h")}
|
||||
style={{
|
||||
padding: "6px 12px",
|
||||
marginBottom: "10px",
|
||||
background: "#333",
|
||||
color: "white",
|
||||
borderRadius: 6,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Rotate Ship ({dir.toUpperCase()})
|
||||
</button>
|
||||
|
||||
{/* GRID */}
|
||||
<motion.div
|
||||
layout
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${cols}, 36px)`,
|
||||
gap: "4px",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{shipBoard.map((row, r) =>
|
||||
row.map((cell, c) => {
|
||||
const preview = isPreviewCell(r, c);
|
||||
const valid = isPreviewValid();
|
||||
|
||||
let bg = "#0a0a0a";
|
||||
|
||||
if (cell === "S") {
|
||||
bg = "#3498db"; // placed ship
|
||||
} else if (preview) {
|
||||
bg = valid ? "rgba(46, 204, 113, 0.6)" : "rgba(231, 76, 60, 0.6)";
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={`${r}-${c}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
onMouseEnter={() => {
|
||||
setHoverR(r);
|
||||
setHoverC(c);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setHoverR(null);
|
||||
setHoverC(null);
|
||||
}}
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
border: "1px solid #333",
|
||||
background: bg,
|
||||
borderRadius: 4,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +1,43 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
import getHaiku from "./utils/haikus";
|
||||
import { useNakama } from "../../providers/NakamaProvider";
|
||||
import getHaiku from "../../utils/haikus";
|
||||
import { PlayerModel } from "../../models/player";
|
||||
|
||||
interface BoardProps {
|
||||
board: string[][];
|
||||
boards: Record<string, { grid: string[][] }>;
|
||||
turn: number;
|
||||
winner: string | null;
|
||||
players: string[];
|
||||
gameOver: boolean | null;
|
||||
players: PlayerModel[];
|
||||
myUserId: string | null;
|
||||
onCellClick: (row: number, col: number) => void;
|
||||
}
|
||||
|
||||
export default function Board({
|
||||
board,
|
||||
export default function TicTacToeBoard({
|
||||
boards,
|
||||
turn,
|
||||
winner,
|
||||
gameOver,
|
||||
players,
|
||||
myUserId,
|
||||
onCellClick,
|
||||
}: BoardProps) {
|
||||
const myIndex = players.indexOf(myUserId ?? "");
|
||||
const myIndex = players.findIndex(p => p.user_id === myUserId);
|
||||
const gameReady = players.length === 2;
|
||||
const {
|
||||
matchId
|
||||
} = useNakama();
|
||||
|
||||
const mySymbol =
|
||||
myIndex === 0 ? "X" : myIndex === 1 ? "O" : null;
|
||||
myIndex !== null && players[myIndex]
|
||||
? players[myIndex].metadata?.symbol ?? null
|
||||
: null;
|
||||
|
||||
const opponentSymbol =
|
||||
mySymbol === "X" ? "O" : mySymbol === "O" ? "X" : null;
|
||||
myIndex !== null && players.length === 2
|
||||
? players[1 - myIndex].metadata?.symbol ?? null
|
||||
: null;
|
||||
|
||||
const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex;
|
||||
|
||||
@@ -42,6 +49,8 @@ export default function Board({
|
||||
status = "Waiting for opponent...";
|
||||
} else if (winner) {
|
||||
status = `Winner: ${winner}`;
|
||||
} else if (gameOver) {
|
||||
status = `Draw!!!`;
|
||||
} else if (myIndex === -1) {
|
||||
status = "Spectating";
|
||||
} else {
|
||||
@@ -53,6 +62,7 @@ export default function Board({
|
||||
const nextLineIn = 3600;
|
||||
const allLinesStay = 2400;
|
||||
const allLinesFade = 1200;
|
||||
const board = boards['tictactoe']?.grid ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
const totalTime = haiku.length * nextLineIn + allLinesStay + allLinesFade;
|
||||
@@ -98,7 +108,9 @@ export default function Board({
|
||||
{/* -------------------------
|
||||
BOARD
|
||||
-------------------------- */}
|
||||
<motion.div
|
||||
{!board && <div style={{ textAlign: "center", marginTop: "14px" }}>Loading...</div>}
|
||||
|
||||
{board && <motion.div
|
||||
layout
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -171,7 +183,7 @@ export default function Board({
|
||||
);
|
||||
})
|
||||
)}
|
||||
</motion.div>
|
||||
</motion.div>}
|
||||
|
||||
{!winner && (
|
||||
<div
|
||||
@@ -4,9 +4,9 @@ import {
|
||||
ApiLeaderboardRecordList,
|
||||
// @ts-ignore
|
||||
} from "@heroiclabs/nakama-js/dist/api.gen"
|
||||
import { useNakama } from "./providers/NakamaProvider";
|
||||
import { useNakama } from "../../providers/NakamaProvider";
|
||||
|
||||
export default function Leaderboard({
|
||||
export default function TicTacToeLeaderboard({
|
||||
intervalMs = 10000,
|
||||
}: {
|
||||
intervalMs?: number;
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import TicTacToe from './tictactoe/TicTacToe';
|
||||
import { NakamaProvider } from './tictactoe/providers/NakamaProvider';
|
||||
import "./tictactoe/styles.css";
|
||||
import App from './App';
|
||||
import { NakamaProvider } from './providers/NakamaProvider';
|
||||
import "./styles.css";
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const root = createRoot(rootElement);
|
||||
|
||||
root.render(
|
||||
<NakamaProvider>
|
||||
<TicTacToe />
|
||||
<App />
|
||||
</NakamaProvider>,
|
||||
);
|
||||
|
||||
6
src/models/player.ts
Normal file
6
src/models/player.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface PlayerModel {
|
||||
user_id: string;
|
||||
username: string;
|
||||
index: number;
|
||||
metadata: Record<string, string>; // e.g. { symbol: "X" }
|
||||
}
|
||||
@@ -25,6 +25,11 @@ function getOrCreateDeviceId(): string {
|
||||
return id;
|
||||
}
|
||||
|
||||
type GameMetadata = {
|
||||
game: string;
|
||||
mode: string;
|
||||
};
|
||||
|
||||
export interface NakamaContextType {
|
||||
client: Client;
|
||||
socket: Socket | null;
|
||||
@@ -33,7 +38,8 @@ export interface NakamaContextType {
|
||||
|
||||
loginOrRegister(username: string): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
joinMatchmaker(mode: string): Promise<string>;
|
||||
joinMatchmaker(gameMetadata: GameMetadata): Promise<string>;
|
||||
exitMatchmaker(gameMetadata: GameMetadata): Promise<void>;
|
||||
joinMatch(matchId: string): Promise<void>;
|
||||
|
||||
sendMatchData(matchId: string, op: number, data: object): void;
|
||||
@@ -46,19 +52,27 @@ export interface NakamaContextType {
|
||||
export const NakamaContext = createContext<NakamaContextType>(null!);
|
||||
|
||||
export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
console.log(
|
||||
"[Nakama] Initializing...",
|
||||
// import.meta.env.VITE_WS_SKEY,
|
||||
import.meta.env.VITE_WS_HOST,
|
||||
import.meta.env.VITE_WS_PORT,
|
||||
import.meta.env.VITE_WS_SSL === "true"
|
||||
);
|
||||
const [client] = useState(
|
||||
() => new Client(
|
||||
import.meta.env.VITE_SERVER_KEY,
|
||||
import.meta.env.VITE_WS_SKEY,
|
||||
import.meta.env.VITE_WS_HOST,
|
||||
import.meta.env.VITE_WS_PORT,
|
||||
import.meta.env.VITE_WS_SSL === "true"
|
||||
)
|
||||
);
|
||||
|
||||
const gameMetadataRef = React.useRef<GameMetadata | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [socket, setSocket] = useState<Socket | null>(null);
|
||||
const [matchmakerTicket, setMatchmakerTicket] = useState<string | null>(null);
|
||||
const [matchId, setMatchId] = useState<string | null>(null);
|
||||
const lastModeRef = React.useRef<string | null>(null);
|
||||
const socketRef = React.useRef<Socket | null>(null);
|
||||
|
||||
async function autoLogin() {
|
||||
@@ -117,8 +131,10 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
const newSession = await getSession(username);
|
||||
setSession(newSession);
|
||||
|
||||
// create a socket (new Nakama 3.x signature)
|
||||
const s = client.createSocket(undefined, undefined); // no SSL on localhost
|
||||
const s = client.createSocket(
|
||||
import.meta.env.VITE_WS_SSL === "true",
|
||||
undefined
|
||||
);
|
||||
await s.connect(newSession, true);
|
||||
setSocket(s);
|
||||
socketRef.current = s;
|
||||
@@ -131,9 +147,9 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!matched.match_id) {
|
||||
console.warn("[Nakama] Match rejected by server. Auto-requeueing...");
|
||||
|
||||
if (lastModeRef.current) {
|
||||
if (gameMetadataRef.current) {
|
||||
try {
|
||||
await joinMatchmaker(lastModeRef.current);
|
||||
await joinMatchmaker(gameMetadataRef.current);
|
||||
} catch (e) {
|
||||
console.error("[Nakama] Requeue failed:", e);
|
||||
}
|
||||
@@ -176,23 +192,44 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
// ----------------------------------------------------
|
||||
// MATCHMAKING
|
||||
// ----------------------------------------------------
|
||||
async function joinMatchmaker(mode: string) {
|
||||
async function joinMatchmaker(gameMetadata: GameMetadata) {
|
||||
const socket = socketRef.current;
|
||||
const game = gameMetadata.game;
|
||||
const mode = gameMetadata.mode;
|
||||
if (!socket) throw new Error("socket missing");
|
||||
|
||||
console.log(`[Nakama] Matchmaking... with +mode:"${mode}"`);
|
||||
if (!game || game.trim() === "") {
|
||||
throw new Error("Matchmaking requires a game name");
|
||||
}
|
||||
|
||||
if (!mode || mode.trim() === "") {
|
||||
throw new Error("Matchmaking requires a mode");
|
||||
}
|
||||
console.log(`[Nakama] Matchmaking... game="${game}" mode="${mode}"`);
|
||||
const ticket: MatchmakerTicket = await socket.addMatchmaker(
|
||||
`*`, // query
|
||||
2, // min count
|
||||
2, // max count
|
||||
{ mode } // stringProperties
|
||||
{ game, mode }
|
||||
);
|
||||
|
||||
lastModeRef.current = mode;
|
||||
gameMetadataRef.current = { game, mode };
|
||||
setMatchmakerTicket(ticket.ticket);
|
||||
|
||||
return ticket.ticket;
|
||||
}
|
||||
|
||||
async function exitMatchmaker(gameMetadata: GameMetadata) {
|
||||
const socket = socketRef.current;
|
||||
const game = gameMetadata.game;
|
||||
const mode = gameMetadata.mode;
|
||||
if (!socket) throw new Error("socket missing");
|
||||
|
||||
console.log(`[Nakama] Exiting Matchmaking... game="${game}" mode="${mode}"`);
|
||||
if (matchmakerTicket) await socket.removeMatchmaker(matchmakerTicket);
|
||||
setMatchmakerTicket(null);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------
|
||||
// EXPLICIT MATCH JOIN
|
||||
// ----------------------------------------------------
|
||||
@@ -208,6 +245,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
// ----------------------------------------------------
|
||||
function sendMatchData(matchId: string, op: number, data: object) {
|
||||
if (!socket) return;
|
||||
console.log("[Nakama] Sending match state:", matchId, op, data);
|
||||
socket.sendMatchState(matchId, op, JSON.stringify(data));
|
||||
}
|
||||
|
||||
@@ -260,6 +298,7 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
||||
loginOrRegister,
|
||||
logout,
|
||||
joinMatchmaker,
|
||||
exitMatchmaker,
|
||||
joinMatch,
|
||||
sendMatchData,
|
||||
onMatchData,
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Match } from "@heroiclabs/nakama-js";
|
||||
|
||||
interface MatchListProps {
|
||||
matches: Match[];
|
||||
}
|
||||
|
||||
export default function MatchList({ matches }: MatchListProps) {
|
||||
if (!matches.length) return <p>No open matches</p>;
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: "20px" }}>
|
||||
<h3>Open Matches</h3>
|
||||
|
||||
<table
|
||||
style={{
|
||||
width: "100%",
|
||||
borderCollapse: "collapse",
|
||||
marginTop: "10px",
|
||||
}}
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={th}>Sr No.</th>
|
||||
<th style={th}>Match ID</th>
|
||||
<th style={th}>Label</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{matches
|
||||
.filter(m => m.size ?? 0 > 0)
|
||||
.map((m, index) => (
|
||||
<tr key={m.match_id}>
|
||||
<td style={td}>{index + 1}</td>
|
||||
<td style={td}>{m.match_id}</td>
|
||||
<td style={td}>{m.label ?? "-"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const th: React.CSSProperties = {
|
||||
textAlign: "left",
|
||||
padding: "8px",
|
||||
background: "#f2f2f2",
|
||||
borderBottom: "1px solid #ccc",
|
||||
};
|
||||
|
||||
const td: React.CSSProperties = {
|
||||
padding: "8px",
|
||||
borderBottom: "1px solid #eee",
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
interface SquareProps {
|
||||
value: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export default function Square({ value, onClick } : SquareProps) {
|
||||
return (
|
||||
<button className="square" onClick={onClick}>
|
||||
{value}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
1
src/vite-env.d.ts
vendored
@@ -4,6 +4,7 @@ interface ImportMetaEnv {
|
||||
readonly VITE_WS_HOST: string;
|
||||
readonly VITE_WS_PORT: string;
|
||||
readonly VITE_WS_SKEY: string;
|
||||
readonly VITE_WS_SSL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
Reference in New Issue
Block a user