Compare commits
50 Commits
v0.2.1
...
code-restr
| Author | SHA1 | Date | |
|---|---|---|---|
| b00519347a | |||
| 8436cdbcdd | |||
| 135fdd332d | |||
| 8dc41fca2c | |||
| fc7cb8efb6 | |||
| 06bdc92190 | |||
| 650d7b7ed6 | |||
| 68c2e3a8d9 | |||
| ab9dd42689 | |||
| 51b051b34c | |||
| eb6749dc0b | |||
| ee31b010ac | |||
| 81a54aa93e | |||
| fe1cacb5ed | |||
| 2b0af9fd1f | |||
| 7b677653a7 | |||
| 5c75541c25 | |||
| 83ae342499 | |||
| cc1f45457c | |||
| df0f502191 | |||
| 16355d8028 | |||
| 7671e9b2cc | |||
| fa02e8b4e4 | |||
| fc29111fe1 | |||
| 5b49e5d584 | |||
| 55aac72bd2 | |||
| b10639316e | |||
| d782832fc5 | |||
| b25cd1a039 | |||
| e269dfc208 | |||
| 066f8fbea5 | |||
| 4a3daf7d8c | |||
| 13ad4e08d2 | |||
| 7e35cf0c31 | |||
| 18d43f9481 | |||
| 5662d22481 | |||
| a996fe7b3c | |||
| ecb8825734 | |||
| d4edf049ed | |||
| d1f4ec0266 | |||
| 56de23f153 | |||
| c4b44e872a | |||
| 0e22d1cd53 | |||
| 0fa644dbc0 | |||
| 0d167b8ccc | |||
| 601048f0e4 | |||
| ebc6906bf6 | |||
| ca7ff9d38e | |||
| 8555675740 | |||
| a9e2d50b16 |
40
.dockerignore
Normal file
40
.dockerignore
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
.next
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE / Editor folders
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.sublime-workspace
|
||||||
|
*.sublime-project
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
*.swp
|
||||||
|
*.bak
|
||||||
|
*.tmp
|
||||||
146
.drone.yml
Normal file
146
.drone.yml
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
|
||||||
|
platform:
|
||||||
|
os: linux
|
||||||
|
arch: arm64
|
||||||
|
|
||||||
|
workspace:
|
||||||
|
path: /drone/src
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
host:
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: fetch-tags
|
||||||
|
image: docker:24
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- apk add --no-cache git
|
||||||
|
- git fetch --tags
|
||||||
|
- |
|
||||||
|
# Get latest Git tag and trim newline
|
||||||
|
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null | tr -d '\n')
|
||||||
|
echo "Latest Git tag fetched: $LATEST_TAG"
|
||||||
|
|
||||||
|
# Save to file for downstream steps
|
||||||
|
echo "$LATEST_TAG" > /drone/src/LATEST_TAG.txt
|
||||||
|
|
||||||
|
# Read back for verification
|
||||||
|
IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
echo "Image tag read from file: $IMAGE_TAG"
|
||||||
|
|
||||||
|
# Validate
|
||||||
|
if [ -z "$IMAGE_TAG" ]; then
|
||||||
|
echo "❌ No git tags found! Cannot continue."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: check-remote-image
|
||||||
|
image: docker:24
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
|
||||||
|
- echo "Checking if lila-games/tic-tac-toe-ui:$IMAGE_TAG exists on remote Docker..."
|
||||||
|
- echo "Existing Docker tags for lila-games/tic-tac-toe-ui:"
|
||||||
|
- docker images --format "{{.Repository}}:{{.Tag}}" | grep "^lila-games/tic-tac-toe-ui" || echo "(none)"
|
||||||
|
- |
|
||||||
|
if docker image inspect lila-games/tic-tac-toe-ui:$IMAGE_TAG > /dev/null 2>&1; then
|
||||||
|
echo "✅ Docker image lila-games/tic-tac-toe-ui:$IMAGE_TAG already exists — skipping build"
|
||||||
|
exit 78
|
||||||
|
else
|
||||||
|
echo "⚙️ Docker image lila-games/tic-tac-toe-ui:$IMAGE_TAG not found — proceeding to build..."
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: build-image
|
||||||
|
image: docker:24
|
||||||
|
environment:
|
||||||
|
WS_HOST:
|
||||||
|
from_secret: WS_HOST
|
||||||
|
WS_PORT:
|
||||||
|
from_secret: WS_PORT
|
||||||
|
WS_SKEY:
|
||||||
|
from_secret: WS_SKEY
|
||||||
|
WS_SSL:
|
||||||
|
from_secret: WS_SSL
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
|
||||||
|
- echo "🔨 Building Docker image lila-games/tic-tac-toe-ui:$IMAGE_TAG ..."
|
||||||
|
- |
|
||||||
|
docker build --network=host \
|
||||||
|
--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
|
||||||
|
|
||||||
|
- name: push-image
|
||||||
|
image: docker:24
|
||||||
|
environment:
|
||||||
|
REGISTRY_HOST:
|
||||||
|
from_secret: REGISTRY_HOST
|
||||||
|
REGISTRY_USER:
|
||||||
|
from_secret: REGISTRY_USER
|
||||||
|
REGISTRY_PASS:
|
||||||
|
from_secret: REGISTRY_PASS
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
|
||||||
|
- echo "🔑 Logging into registry $REGISTRY_HOST ..."
|
||||||
|
- echo "$REGISTRY_PASS" | docker login $REGISTRY_HOST -u "$REGISTRY_USER" --password-stdin
|
||||||
|
- echo "🏷️ Tagging images with registry prefix..."
|
||||||
|
- docker tag lila-games/tic-tac-toe-ui:$IMAGE_TAG $REGISTRY_HOST/lila-games/tic-tac-toe-ui:$IMAGE_TAG
|
||||||
|
- docker tag lila-games/tic-tac-toe-ui:$IMAGE_TAG $REGISTRY_HOST/lila-games/tic-tac-toe-ui:latest
|
||||||
|
- echo "📤 Pushing lila-games/tic-tac-toe-ui:$IMAGE_TAG ..."
|
||||||
|
- docker push $REGISTRY_HOST/lila-games/tic-tac-toe-ui:$IMAGE_TAG
|
||||||
|
- echo "📤 Pushing lila-games/tic-tac-toe-ui:latest ..."
|
||||||
|
- docker push $REGISTRY_HOST/lila-games/tic-tac-toe-ui:latest
|
||||||
|
|
||||||
|
- name: stop-old
|
||||||
|
image: docker:24
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- echo "🛑 Stopping old container..."
|
||||||
|
- docker rm -f tic-tac-toe-ui || true
|
||||||
|
|
||||||
|
- name: run-container
|
||||||
|
image: docker:24
|
||||||
|
volumes:
|
||||||
|
- name: dockersock
|
||||||
|
path: /var/run/docker.sock
|
||||||
|
commands:
|
||||||
|
- IMAGE_TAG=$(cat /drone/src/LATEST_TAG.txt | tr -d '\n')
|
||||||
|
|
||||||
|
- echo "🚀 Starting container lila-games/tic-tac-toe-ui:$IMAGE_TAG ..."
|
||||||
|
- |
|
||||||
|
docker run -d \
|
||||||
|
--name tic-tac-toe-ui \
|
||||||
|
-p 3003:3000 \
|
||||||
|
-e NODE_ENV=production \
|
||||||
|
--restart always \
|
||||||
|
lila-games/tic-tac-toe-ui:$IMAGE_TAG
|
||||||
|
|
||||||
|
# Trigger rules
|
||||||
|
trigger:
|
||||||
|
event:
|
||||||
|
- tag
|
||||||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 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"]
|
||||||
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
|
## 🎮 Overview
|
||||||
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
|
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
|
## 🛠 Tech Stack
|
||||||
npm install
|
|
||||||
npm run dev
|
* **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).
|
### Matchmaking
|
||||||
It includes `@mui/material` and its peer dependencies, including [Emotion](https://emotion.sh/docs/introduction), the default style engine in Material UI.
|
|
||||||
|
|
||||||
## 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.
|
* User sends moves via OpCode **1**
|
||||||
You can head back to the documentation and continue by browsing the [templates](https://mui.com/material-ui/getting-started/templates/) section.
|
* 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
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
|
||||||
/>
|
/>
|
||||||
<title>Blog - Aetoskia</title>
|
<title>TicTacToe - Aetoskia</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
53
package-lock.json
generated
53
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "tictactoe-vite",
|
"name": "tictactoe-vite",
|
||||||
"version": "1.0.0",
|
"version": "v0.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "tictactoe-vite",
|
"name": "tictactoe-vite",
|
||||||
"version": "1.0.0",
|
"version": "v0.2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "latest",
|
"@emotion/react": "latest",
|
||||||
"@emotion/styled": "latest",
|
"@emotion/styled": "latest",
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
"@mui/icons-material": "latest",
|
"@mui/icons-material": "latest",
|
||||||
"@mui/material": "latest",
|
"@mui/material": "latest",
|
||||||
"axios": "latest",
|
"axios": "latest",
|
||||||
|
"framer-motion": "latest",
|
||||||
"markdown-to-jsx": "latest",
|
"markdown-to-jsx": "latest",
|
||||||
"marked": "latest",
|
"marked": "latest",
|
||||||
"react": "latest",
|
"react": "latest",
|
||||||
@@ -2064,6 +2065,33 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.23.24",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz",
|
||||||
|
"integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.23.23",
|
||||||
|
"motion-utils": "^12.23.6",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -3263,6 +3291,21 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.23.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
|
||||||
|
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.23.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.23.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
|
||||||
|
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -3761,6 +3804,12 @@
|
|||||||
"url": "https://github.com/sponsors/wooorm"
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/unified": {
|
"node_modules/unified": {
|
||||||
"version": "11.0.5",
|
"version": "11.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "tictactoe-vite",
|
"name": "tictactoe-vite",
|
||||||
"version": "v0.2.0",
|
"version": "v1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -19,7 +19,8 @@
|
|||||||
"remark-gfm": "latest",
|
"remark-gfm": "latest",
|
||||||
"marked": "latest",
|
"marked": "latest",
|
||||||
"axios": "latest",
|
"axios": "latest",
|
||||||
"@heroiclabs/nakama-js": "^2.8.0"
|
"@heroiclabs/nakama-js": "^2.8.0",
|
||||||
|
"framer-motion": "latest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-react": "latest",
|
"@vitejs/plugin-react": "latest",
|
||||||
|
|||||||
165
src/App.tsx
Normal file
165
src/App.tsx
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useNakama } from "./providers/NakamaProvider";
|
||||||
|
|
||||||
|
import Player from "./Player";
|
||||||
|
import TicTacToeGame from "./games/tictactoe/TicTacToeGame";
|
||||||
|
import { TicTacToeGameProps } from "./games/tictactoe/props";
|
||||||
|
import BattleshipGame from "./games/battleship/BattleshipGame"
|
||||||
|
import { BattleshipGameProps } from "./games/battleship/props";
|
||||||
|
|
||||||
|
import { GameState } from "./interfaces/states";
|
||||||
|
import { GameProps } from "./interfaces/props";
|
||||||
|
|
||||||
|
const INITIAL_GAME_STATE: GameState = {
|
||||||
|
boards: {},
|
||||||
|
turn: 0,
|
||||||
|
winner: null,
|
||||||
|
gameOver: false,
|
||||||
|
players: [],
|
||||||
|
metadata: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
// unified game state
|
||||||
|
const [game, setGame] = useState<GameState>(INITIAL_GAME_STATE);
|
||||||
|
const { onMatchData, matchId, session } = useNakama();
|
||||||
|
|
||||||
|
const commonProps: GameProps = {
|
||||||
|
boards: game.boards,
|
||||||
|
turn: game.turn,
|
||||||
|
winner: game.winner,
|
||||||
|
gameOver: game.gameOver,
|
||||||
|
players: game.players,
|
||||||
|
myUserId: session?.user_id ?? null,
|
||||||
|
};
|
||||||
|
const ticTacToeProps: TicTacToeGameProps = {
|
||||||
|
...commonProps,
|
||||||
|
};
|
||||||
|
const battleshipProps: BattleshipGameProps = {
|
||||||
|
...commonProps,
|
||||||
|
metadata: game.metadata,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------
|
||||||
|
// RENDER GAME BOARD
|
||||||
|
// ---------------------------------------------------
|
||||||
|
function renderGameBoard() {
|
||||||
|
if (!matchId || !game.metadata?.game) return null;
|
||||||
|
|
||||||
|
switch (game.metadata.game) {
|
||||||
|
case "tictactoe":
|
||||||
|
return (
|
||||||
|
<TicTacToeGame
|
||||||
|
{...ticTacToeProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "battleship":
|
||||||
|
return (
|
||||||
|
<BattleshipGame
|
||||||
|
{...battleshipProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <div>Unknown game: {game.metadata.game}</div>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------
|
||||||
|
// MATCH DATA CALLBACK (from Player component)
|
||||||
|
// ------------------------------------------
|
||||||
|
function onMatchDataCallback(msg: { opCode: number; data: any }) {
|
||||||
|
console.log("[Match Data]", msg);
|
||||||
|
|
||||||
|
if (msg.opCode === 2) {
|
||||||
|
const state = msg.data;
|
||||||
|
console.log("Match state:", state);
|
||||||
|
|
||||||
|
setGame({
|
||||||
|
boards: state.boards,
|
||||||
|
turn: state.turn,
|
||||||
|
gameOver: state.game_over,
|
||||||
|
winner:
|
||||||
|
state.winner >= 0 ? state.players[state.winner].username : null,
|
||||||
|
players: state.players ?? [],
|
||||||
|
metadata: state.metadata ?? {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------
|
||||||
|
// EFFECTS
|
||||||
|
// ---------------------------------------------------
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = "auto";
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onMatchData(onMatchDataCallback);
|
||||||
|
}, [onMatchData]);
|
||||||
|
|
||||||
|
// ---------------------------------------------------
|
||||||
|
// UI LAYOUT
|
||||||
|
// ---------------------------------------------------
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
height: "100vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
background: "#060606",
|
||||||
|
color: "white",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* ---------------- HEADER (always fixed at top) ---------------- */}
|
||||||
|
<header
|
||||||
|
style={{
|
||||||
|
padding: "16px 20px",
|
||||||
|
background: "rgba(255,255,255,0.04)",
|
||||||
|
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||||
|
backdropFilter: "blur(6px)",
|
||||||
|
textAlign: "center",
|
||||||
|
fontSize: "26px",
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: "1px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Games
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* ---------------- MAIN CONTENT (scrolls) ---------------- */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
overflowY: "auto",
|
||||||
|
padding: "20px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Player onMatchDataCallback={onMatchDataCallback} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: "20px",
|
||||||
|
background: "rgba(255,255,255,0.03)",
|
||||||
|
borderRadius: "20px",
|
||||||
|
boxShadow: "0 6px 20px rgba(0,0,0,0.4)",
|
||||||
|
minWidth: "300px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{renderGameBoard()}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
319
src/Player.tsx
Normal file
319
src/Player.tsx
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useNakama } from "./providers/NakamaProvider";
|
||||||
|
import { PlayerProps } from "./interfaces/props";
|
||||||
|
|
||||||
|
export default function Player({
|
||||||
|
onMatchDataCallback,
|
||||||
|
}: PlayerProps) {
|
||||||
|
const {
|
||||||
|
session,
|
||||||
|
matchId,
|
||||||
|
loginOrRegister,
|
||||||
|
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";
|
||||||
|
|
||||||
|
// ------------------------------------------
|
||||||
|
// CONNECT
|
||||||
|
// ------------------------------------------
|
||||||
|
async function handleConnect() {
|
||||||
|
await loginOrRegister(username);
|
||||||
|
|
||||||
|
// Match data listener
|
||||||
|
onMatchData(onMatchDataCallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------
|
||||||
|
// MATCHMAKING
|
||||||
|
// ------------------------------------------
|
||||||
|
async function startQueue(
|
||||||
|
selectedGame: string,
|
||||||
|
selectedMode: string
|
||||||
|
) {
|
||||||
|
setIsQueueing(true);
|
||||||
|
const gameMetadata = {
|
||||||
|
game: selectedGame,
|
||||||
|
mode: selectedMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ticket = await joinMatchmaker(gameMetadata);
|
||||||
|
console.log("Queued:", ticket);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Matchmaking failed:", err);
|
||||||
|
setIsQueueing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelQueue(
|
||||||
|
selectedGame: string,
|
||||||
|
selectedMode: string
|
||||||
|
) {
|
||||||
|
setIsQueueing(false);
|
||||||
|
|
||||||
|
const gameMetadata = {
|
||||||
|
game: selectedGame,
|
||||||
|
mode: selectedMode,
|
||||||
|
}
|
||||||
|
await exitMatchmaker(gameMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleConnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ marginBottom: "20px" }}>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{/* ---------------- LOGIN SCREEN ---------------- */}
|
||||||
|
{!session && (
|
||||||
|
<motion.div
|
||||||
|
key="login"
|
||||||
|
initial={{ opacity: 0, y: 20, scale: 0.97 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: -20, scale: 0.97 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||||
|
style={{
|
||||||
|
background: "#111",
|
||||||
|
padding: "24px",
|
||||||
|
borderRadius: "16px",
|
||||||
|
width: "280px",
|
||||||
|
margin: "0 auto",
|
||||||
|
color: "white",
|
||||||
|
boxShadow:
|
||||||
|
"0 4px 16px rgba(0,0,0,0.4), inset 0 0 20px rgba(255,255,255,0.03)",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: "16px" }}>Welcome!</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
placeholder="Enter username"
|
||||||
|
value={username}
|
||||||
|
disabled={isRegistered}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: "10px",
|
||||||
|
width: "100%",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "#222",
|
||||||
|
color: "white",
|
||||||
|
border: "1px solid #333",
|
||||||
|
marginBottom: "12px",
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={handleConnect}
|
||||||
|
disabled={username.length === 0}
|
||||||
|
style={{
|
||||||
|
width: "100%",
|
||||||
|
padding: "10px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: username.length ? "#2ecc71" : "#444",
|
||||||
|
border: "none",
|
||||||
|
cursor: username.length ? "pointer" : "not-allowed",
|
||||||
|
color: "white",
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: "14px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</motion.button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- LOBBY SCREEN ---------------- */}
|
||||||
|
{session && !matchId && (
|
||||||
|
<motion.div
|
||||||
|
key="lobby"
|
||||||
|
initial={{ opacity: 0, y: 40 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -40 }}
|
||||||
|
transition={{ duration: 0.45, ease: "easeOut" }}
|
||||||
|
style={{
|
||||||
|
padding: "20px",
|
||||||
|
background: "#0f0f0f",
|
||||||
|
borderRadius: "20px",
|
||||||
|
color: "white",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: "10px" }}>
|
||||||
|
Hello, <span style={{ color: "#2ecc71" }}>{session.username}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<label style={{ display: "block", marginTop: "10px", opacity: 0.7 }}>
|
||||||
|
Select Game Mode
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={selectedGame}
|
||||||
|
disabled={isQueueing}
|
||||||
|
onChange={(e) => setSelectedGame(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: "8px",
|
||||||
|
margin: "10px 0 16px",
|
||||||
|
width: "60%",
|
||||||
|
borderRadius: "10px",
|
||||||
|
background: "#222",
|
||||||
|
color: "white",
|
||||||
|
border: "1px solid #333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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(
|
||||||
|
selectedGame,
|
||||||
|
selectedMode,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
padding: "10px 20px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "#3498db",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
marginRight: "10px",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Join Matchmaking
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Queueing animation */}
|
||||||
|
{isQueueing && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
style={{
|
||||||
|
marginTop: "10px",
|
||||||
|
marginBottom: "10px",
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "#222",
|
||||||
|
color: "white",
|
||||||
|
display: "inline-block",
|
||||||
|
fontSize: "14px",
|
||||||
|
border: "1px solid #333",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: "6px", fontWeight: 600 }}>
|
||||||
|
Finding an opponent…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Animated pulsing dots */}
|
||||||
|
<motion.div
|
||||||
|
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||||
|
transition={{ duration: 1.2, repeat: Infinity }}
|
||||||
|
style={{ letterSpacing: "2px", fontSize: "18px" }}
|
||||||
|
>
|
||||||
|
● ● ●
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Cancel button */}
|
||||||
|
<button
|
||||||
|
onClick={() => cancelQueue(
|
||||||
|
selectedGame,
|
||||||
|
selectedMode,
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
marginTop: "10px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
borderRadius: "8px",
|
||||||
|
background: "#e74c3c",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontSize: "12px",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: "24px" }}>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<motion.button
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={logout}
|
||||||
|
style={{
|
||||||
|
padding: "10px 20px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
background: "#e74c3c",
|
||||||
|
color: "white",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</motion.button>
|
||||||
|
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- MATCH SCREEN ---------------- */}
|
||||||
|
{session && matchId && (
|
||||||
|
<motion.div
|
||||||
|
key="match"
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: 30 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: -20 }}
|
||||||
|
transition={{ duration: 0.35, ease: "easeOut" }}
|
||||||
|
style={{
|
||||||
|
padding: "20px",
|
||||||
|
color: "white",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 style={{ marginBottom: "10px" }}>
|
||||||
|
Go, <span style={{ color: "#2ecc71" }}>{session.username}</span>
|
||||||
|
</h2>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
132
src/games/battleship/BattleshipGame.tsx
Normal file
132
src/games/battleship/BattleshipGame.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import React, { useMemo } from "react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { useNakama } from "../../providers/NakamaProvider";
|
||||||
|
|
||||||
|
import PlacementGrid from "./placement/PlacementGrid";
|
||||||
|
import ShotGrid from "./battle/ShotGrid";
|
||||||
|
import { BattleshipGameProps } from "./props";
|
||||||
|
import { BattleshipPayload } from "./models";
|
||||||
|
import {
|
||||||
|
placePayload,
|
||||||
|
shootPayload,
|
||||||
|
} from "./utils";
|
||||||
|
|
||||||
|
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 BattleshipGame({
|
||||||
|
boards,
|
||||||
|
players,
|
||||||
|
myUserId,
|
||||||
|
turn,
|
||||||
|
winner,
|
||||||
|
gameOver,
|
||||||
|
metadata,
|
||||||
|
}: BattleshipGameProps) {
|
||||||
|
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;
|
||||||
|
|
||||||
|
function handleMove(matchPayload: BattleshipPayload) {
|
||||||
|
if (!matchId) return;
|
||||||
|
|
||||||
|
sendMatchData(matchId!, 1, matchPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------- 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={(
|
||||||
|
s,r,c,d
|
||||||
|
) => handleMove(
|
||||||
|
placePayload(s,r,c,d)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ---------------- PHASE 2: BATTLE ---------------- */}
|
||||||
|
{phase === "battle" && (
|
||||||
|
<>
|
||||||
|
<h3>Your Shots</h3>
|
||||||
|
|
||||||
|
<ShotGrid
|
||||||
|
grid={myShots}
|
||||||
|
isMyTurn={isMyTurn}
|
||||||
|
gameOver={!!gameOver}
|
||||||
|
onShoot={(
|
||||||
|
r,c
|
||||||
|
) => handleMove(
|
||||||
|
shootPayload(r,c)
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/games/battleship/models.ts
Normal file
15
src/games/battleship/models.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import {
|
||||||
|
MatchDataModel,
|
||||||
|
} from '../../interfaces/models'
|
||||||
|
|
||||||
|
export interface BattleshipPayload {
|
||||||
|
action: "place" | "shoot"; // extend as needed
|
||||||
|
data: {
|
||||||
|
ship?: string; // only for placement
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
dir?: "h" | "v";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BattleshipMatchDataModel = MatchDataModel<BattleshipPayload>;
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/games/battleship/props.ts
Normal file
8
src/games/battleship/props.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import {
|
||||||
|
GameProps,
|
||||||
|
} from '../../interfaces/props'
|
||||||
|
|
||||||
|
|
||||||
|
export interface BattleshipGameProps extends GameProps {
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
}
|
||||||
22
src/games/battleship/utils.ts
Normal file
22
src/games/battleship/utils.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
BattleshipPayload
|
||||||
|
} from "./models";
|
||||||
|
|
||||||
|
export function placePayload(
|
||||||
|
ship: string,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
dir: "h" | "v"
|
||||||
|
): BattleshipPayload {
|
||||||
|
return {
|
||||||
|
action: "place",
|
||||||
|
data: { ship, row, col, dir }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shootPayload(row: number, col: number): BattleshipPayload {
|
||||||
|
return {
|
||||||
|
action: "shoot",
|
||||||
|
data: { row, col }
|
||||||
|
};
|
||||||
|
}
|
||||||
267
src/games/tictactoe/TicTacToeGame.tsx
Normal file
267
src/games/tictactoe/TicTacToeGame.tsx
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { useNakama } from "../../providers/NakamaProvider";
|
||||||
|
import getHaiku from "../../utils/haikus";
|
||||||
|
|
||||||
|
import { TicTacToeGameProps } from "./props";
|
||||||
|
import { TicTacToePayload } from "./models";
|
||||||
|
import { movePayload } from "./utils";
|
||||||
|
|
||||||
|
export default function TicTacToeGame({
|
||||||
|
boards,
|
||||||
|
turn,
|
||||||
|
winner,
|
||||||
|
gameOver,
|
||||||
|
players,
|
||||||
|
myUserId,
|
||||||
|
}: TicTacToeGameProps) {
|
||||||
|
const { sendMatchData, matchId } = useNakama();
|
||||||
|
|
||||||
|
const myIndex = players.findIndex(p => p.user_id === myUserId);
|
||||||
|
const gameReady = players.length === 2;
|
||||||
|
|
||||||
|
const mySymbol =
|
||||||
|
myIndex !== null && players[myIndex]
|
||||||
|
? players[myIndex].metadata?.symbol ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const opponentSymbol =
|
||||||
|
myIndex !== null && players.length === 2
|
||||||
|
? players[1 - myIndex].metadata?.symbol ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex;
|
||||||
|
|
||||||
|
// -------------------------------
|
||||||
|
// STATUS
|
||||||
|
// -------------------------------
|
||||||
|
let status;
|
||||||
|
if (!gameReady) {
|
||||||
|
status = "Waiting for opponent...";
|
||||||
|
} else if (winner) {
|
||||||
|
status = `Winner: ${winner}`;
|
||||||
|
} else if (gameOver) {
|
||||||
|
status = `Draw!!!`;
|
||||||
|
} else if (myIndex === -1) {
|
||||||
|
status = "Spectating";
|
||||||
|
} else {
|
||||||
|
status = isMyTurn ? "Your turn" : "Opponent's turn";
|
||||||
|
}
|
||||||
|
|
||||||
|
const [haiku, setHaiku] = useState(getHaiku());
|
||||||
|
const [haikuIndex, setHaikuIndex] = useState(0);
|
||||||
|
const nextLineIn = 3600;
|
||||||
|
const allLinesStay = 2400;
|
||||||
|
const allLinesFade = 1200;
|
||||||
|
const board = boards['tictactoe']?.grid ?? null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const totalTime = haiku.length * nextLineIn + allLinesStay + allLinesFade;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
const next = getHaiku();
|
||||||
|
setHaiku(next);
|
||||||
|
setHaikuIndex((i) => i + 1);
|
||||||
|
}, totalTime);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [haikuIndex]);
|
||||||
|
|
||||||
|
function handleMove(matchPayload: TicTacToePayload) {
|
||||||
|
if (!matchId) return;
|
||||||
|
|
||||||
|
sendMatchData(matchId!, 1, matchPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{matchId && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.35 }}
|
||||||
|
>
|
||||||
|
<motion.h2
|
||||||
|
key={status}
|
||||||
|
initial={{ opacity: 0, y: -6 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.25 }}
|
||||||
|
style={{ marginBottom: 8 }}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</motion.h2>
|
||||||
|
|
||||||
|
{gameReady && mySymbol && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 0.75 }}
|
||||||
|
style={{ marginBottom: 8, fontSize: 14 }}
|
||||||
|
>
|
||||||
|
You: <strong>{mySymbol}</strong> — Opponent:{" "}
|
||||||
|
<strong>{opponentSymbol}</strong>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* -------------------------
|
||||||
|
BOARD
|
||||||
|
-------------------------- */}
|
||||||
|
{!board && <div style={{ textAlign: "center", marginTop: "14px" }}>Loading...</div>}
|
||||||
|
|
||||||
|
{board && <motion.div
|
||||||
|
layout
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(3, 80px)",
|
||||||
|
gap: "10px",
|
||||||
|
marginTop: "6px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{board.map((row, rIdx) =>
|
||||||
|
row.map((cell, cIdx) => {
|
||||||
|
const disabled =
|
||||||
|
!!cell ||
|
||||||
|
!!winner ||
|
||||||
|
!gameReady ||
|
||||||
|
myIndex === -1 ||
|
||||||
|
!isMyTurn;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.button
|
||||||
|
key={`${rIdx}-${cIdx}-${cell}`} // rerender when cell changes
|
||||||
|
layout
|
||||||
|
whileHover={
|
||||||
|
!disabled
|
||||||
|
? {
|
||||||
|
scale: 1.1,
|
||||||
|
boxShadow: "0px 0px 10px rgba(255,255,255,0.4)",
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
whileTap={!disabled ? { scale: 0.85 } : {}}
|
||||||
|
onClick={() => !disabled && handleMove(
|
||||||
|
movePayload(rIdx, cIdx)
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
width: "80px",
|
||||||
|
height: "80px",
|
||||||
|
fontSize: "2rem",
|
||||||
|
borderRadius: "10px",
|
||||||
|
border: "2px solid #333",
|
||||||
|
background: "#111",
|
||||||
|
color: "white",
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence>
|
||||||
|
{cell && (
|
||||||
|
<motion.span
|
||||||
|
key="symbol"
|
||||||
|
initial={{ scale: 0.3, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.3, opacity: 0 }}
|
||||||
|
transition={{ type: "spring", stiffness: 200, damping: 12 }}
|
||||||
|
style={{
|
||||||
|
color:
|
||||||
|
winner === cell
|
||||||
|
? "#f1c40f" // highlight winning symbol
|
||||||
|
: "white",
|
||||||
|
textShadow:
|
||||||
|
winner === cell
|
||||||
|
? "0 0 12px rgba(241,196,15,0.8)"
|
||||||
|
: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cell}
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</motion.div>}
|
||||||
|
|
||||||
|
{!winner && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "90px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginTop: "14px",
|
||||||
|
position: "relative",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={haikuIndex}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 2.4,
|
||||||
|
ease: "easeInOut",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: "1.35",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{haiku.map((line, i) => (
|
||||||
|
<motion.div
|
||||||
|
key={i}
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
transition={{
|
||||||
|
delay: i * (nextLineIn / 1000),
|
||||||
|
duration: nextLineIn / 1000,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
fontSize: "18px",
|
||||||
|
color: "#f1c40f",
|
||||||
|
fontWeight: 700,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Winner pulse animation */}
|
||||||
|
{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: "14px",
|
||||||
|
fontWeight: 700,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🎉 {winner} Wins! 🎉
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,9 +4,9 @@ import {
|
|||||||
ApiLeaderboardRecordList,
|
ApiLeaderboardRecordList,
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
} from "@heroiclabs/nakama-js/dist/api.gen"
|
} 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 = 10000,
|
||||||
}: {
|
}: {
|
||||||
intervalMs?: number;
|
intervalMs?: number;
|
||||||
11
src/games/tictactoe/models.ts
Normal file
11
src/games/tictactoe/models.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import {
|
||||||
|
MatchDataModel,
|
||||||
|
} from '../../interfaces/models'
|
||||||
|
|
||||||
|
export interface TicTacToePayload {
|
||||||
|
data: {
|
||||||
|
row: number;
|
||||||
|
col: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export type TicTacToeMatchDataModel = MatchDataModel<TicTacToePayload>;
|
||||||
8
src/games/tictactoe/props.ts
Normal file
8
src/games/tictactoe/props.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import {
|
||||||
|
GameProps,
|
||||||
|
} from '../../interfaces/props'
|
||||||
|
|
||||||
|
|
||||||
|
export interface TicTacToeGameProps extends GameProps {
|
||||||
|
// metadata: Record<string, any>;
|
||||||
|
}
|
||||||
12
src/games/tictactoe/utils.ts
Normal file
12
src/games/tictactoe/utils.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import {
|
||||||
|
TicTacToePayload
|
||||||
|
} from "./models";
|
||||||
|
|
||||||
|
export function movePayload(
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
): TicTacToePayload {
|
||||||
|
return {
|
||||||
|
data: { row, col }
|
||||||
|
};
|
||||||
|
}
|
||||||
38
src/interfaces/contexts.ts
Normal file
38
src/interfaces/contexts.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
Client,
|
||||||
|
Session,
|
||||||
|
Socket,
|
||||||
|
} from "@heroiclabs/nakama-js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ApiMatch,
|
||||||
|
ApiLeaderboardRecordList,
|
||||||
|
// @ts-ignore
|
||||||
|
} from "@heroiclabs/nakama-js/dist/api.gen"
|
||||||
|
|
||||||
|
import {
|
||||||
|
GameMetadataModel,
|
||||||
|
MatchDataModel,
|
||||||
|
} from './models'
|
||||||
|
|
||||||
|
|
||||||
|
export interface NakamaContextType {
|
||||||
|
client: Client;
|
||||||
|
socket: Socket | null;
|
||||||
|
session: Session | null;
|
||||||
|
matchId: string | null;
|
||||||
|
|
||||||
|
loginOrRegister(username?: string): Promise<void>;
|
||||||
|
logout(): Promise<void>;
|
||||||
|
|
||||||
|
joinMatchmaker(gameMetadata: GameMetadataModel): Promise<string>;
|
||||||
|
exitMatchmaker(gameMetadata: GameMetadataModel): Promise<void>;
|
||||||
|
joinMatch(matchId: string): Promise<void>;
|
||||||
|
|
||||||
|
sendMatchData(matchId: string, op: number, data: object): void;
|
||||||
|
|
||||||
|
onMatchData(cb: (msg: MatchDataModel) => void): void;
|
||||||
|
|
||||||
|
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
|
||||||
|
listOpenMatches(): Promise<ApiMatch[]>;
|
||||||
|
}
|
||||||
21
src/interfaces/models.ts
Normal file
21
src/interfaces/models.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface PlayerModel {
|
||||||
|
user_id: string;
|
||||||
|
username: string;
|
||||||
|
index: number;
|
||||||
|
metadata: Record<string, string>; // e.g. { symbol: "X" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MatchDataModel<T = any> {
|
||||||
|
opCode: number;
|
||||||
|
data: T;
|
||||||
|
userId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardModel {
|
||||||
|
grid: string[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameMetadataModel {
|
||||||
|
game: string;
|
||||||
|
mode: string;
|
||||||
|
}
|
||||||
19
src/interfaces/props.ts
Normal file
19
src/interfaces/props.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import {
|
||||||
|
MatchDataModel,
|
||||||
|
} from './models'
|
||||||
|
|
||||||
|
import {
|
||||||
|
GameState
|
||||||
|
} from "./states";
|
||||||
|
|
||||||
|
export interface PlayerProps {
|
||||||
|
onMatchDataCallback: (msg:MatchDataModel) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameProps
|
||||||
|
extends Pick<
|
||||||
|
GameState,
|
||||||
|
"boards" | "turn" | "winner" | "gameOver" | "players"
|
||||||
|
> {
|
||||||
|
myUserId: string | null;
|
||||||
|
}
|
||||||
15
src/interfaces/refs.ts
Normal file
15
src/interfaces/refs.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Socket
|
||||||
|
} from "@heroiclabs/nakama-js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
GameMetadataModel,
|
||||||
|
} from './models'
|
||||||
|
|
||||||
|
|
||||||
|
export interface NakamaRefs {
|
||||||
|
socketRef: React.RefObject<Socket | null>;
|
||||||
|
gameMetadataRef: React.RefObject<GameMetadataModel | null>;
|
||||||
|
}
|
||||||
26
src/interfaces/states.ts
Normal file
26
src/interfaces/states.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
Session,
|
||||||
|
Socket
|
||||||
|
} from "@heroiclabs/nakama-js";
|
||||||
|
|
||||||
|
import {
|
||||||
|
BoardModel,
|
||||||
|
PlayerModel,
|
||||||
|
} from "./models"
|
||||||
|
|
||||||
|
|
||||||
|
export interface NakamaProviderState {
|
||||||
|
session: Session | null;
|
||||||
|
socket: Socket | null;
|
||||||
|
matchId: string | null;
|
||||||
|
matchmakerTicket: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameState {
|
||||||
|
boards: Record<string, BoardModel>;
|
||||||
|
turn: number;
|
||||||
|
winner: string | null;
|
||||||
|
gameOver: boolean;
|
||||||
|
players: PlayerModel[];
|
||||||
|
metadata: Record<string, any>;
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import TicTacToe from './tictactoe/TicTacToe';
|
import App from './App';
|
||||||
import { NakamaProvider } from './tictactoe/providers/NakamaProvider';
|
import { NakamaProvider } from './providers/NakamaProvider';
|
||||||
import "./tictactoe/styles.css";
|
import "./styles.css";
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById('root');
|
||||||
const root = createRoot(rootElement);
|
const root = createRoot(rootElement);
|
||||||
|
|
||||||
root.render(
|
root.render(
|
||||||
<NakamaProvider>
|
<NakamaProvider>
|
||||||
<TicTacToe />
|
<App />
|
||||||
</NakamaProvider>,
|
</NakamaProvider>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useRef
|
||||||
|
} from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Client,
|
Client,
|
||||||
Session,
|
|
||||||
Socket,
|
Socket,
|
||||||
MatchmakerTicket,
|
MatchmakerTicket,
|
||||||
MatchData,
|
MatchData,
|
||||||
@@ -13,7 +19,10 @@ import {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
} from "@heroiclabs/nakama-js/dist/api.gen"
|
} from "@heroiclabs/nakama-js/dist/api.gen"
|
||||||
|
|
||||||
import React, { createContext, useContext, useState } from "react";
|
import { NakamaContextType } from "../interfaces/contexts";
|
||||||
|
import { NakamaRefs } from "../interfaces/refs";
|
||||||
|
import { NakamaProviderState } from "../interfaces/states";
|
||||||
|
import { GameMetadataModel, MatchDataModel } from "../interfaces/models";
|
||||||
|
|
||||||
function getOrCreateDeviceId(): string {
|
function getOrCreateDeviceId(): string {
|
||||||
const key = "nakama.deviceId";
|
const key = "nakama.deviceId";
|
||||||
@@ -25,37 +34,51 @@ function getOrCreateDeviceId(): string {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NakamaContextType {
|
|
||||||
client: Client;
|
|
||||||
socket: Socket | null;
|
|
||||||
session: Session | null;
|
|
||||||
matchId: string | null;
|
|
||||||
|
|
||||||
loginOrRegister(username: string): Promise<void>;
|
|
||||||
logout(): Promise<void>;
|
|
||||||
joinMatchmaker(mode: string): Promise<string>;
|
|
||||||
joinMatch(matchId: string): Promise<void>;
|
|
||||||
|
|
||||||
sendMatchData(matchId: string, op: number, data: object): void;
|
|
||||||
|
|
||||||
onMatchData(cb: (msg: any) => void): void;
|
|
||||||
getLeaderboardTop(): Promise<ApiLeaderboardRecordList>;
|
|
||||||
listOpenMatches(): Promise<ApiMatch[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const NakamaContext = createContext<NakamaContextType>(null!);
|
export const NakamaContext = createContext<NakamaContextType>(null!);
|
||||||
|
|
||||||
export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
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(
|
const [client] = useState(
|
||||||
() => new Client("defaultkey", "127.0.0.1", "7350")
|
() => new Client(
|
||||||
|
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 [session, setSession] = useState<Session | null>(null);
|
// --------------------------------------
|
||||||
const [socket, setSocket] = useState<Socket | null>(null);
|
// INTERNAL STATE (React state)
|
||||||
const [matchId, setMatchId] = useState<string | null>(null);
|
// --------------------------------------
|
||||||
const lastModeRef = React.useRef<string | null>(null);
|
const [internal, setInternal] = useState<NakamaProviderState>({
|
||||||
const socketRef = React.useRef<Socket | null>(null);
|
session: null,
|
||||||
|
socket: null,
|
||||||
|
matchId: null,
|
||||||
|
matchmakerTicket: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --------------------------------------
|
||||||
|
// INTERNAL REFS (non-reactive, stable)
|
||||||
|
// --------------------------------------
|
||||||
|
const refs: NakamaRefs = {
|
||||||
|
socketRef: useRef<Socket | null>(null),
|
||||||
|
gameMetadataRef: useRef<GameMetadataModel | null>(null),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helpers to update internal state cleanly
|
||||||
|
function updateState(values: Partial<NakamaProviderState>) {
|
||||||
|
setInternal(prev => ({ ...prev, ...values }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// LOGIN FLOW
|
||||||
|
// ---------------------------------------
|
||||||
async function autoLogin() {
|
async function autoLogin() {
|
||||||
const deviceId = getOrCreateDeviceId();
|
const deviceId = getOrCreateDeviceId();
|
||||||
|
|
||||||
@@ -110,13 +133,14 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
|||||||
async function loginOrRegister(username?: string) {
|
async function loginOrRegister(username?: string) {
|
||||||
// authenticate user
|
// authenticate user
|
||||||
const newSession = await getSession(username);
|
const newSession = await getSession(username);
|
||||||
setSession(newSession);
|
|
||||||
|
|
||||||
// create a socket (new Nakama 3.x signature)
|
updateState({ session: newSession });
|
||||||
const s = client.createSocket(undefined, undefined); // no SSL on localhost
|
|
||||||
|
const s = client.createSocket(import.meta.env.VITE_WS_SSL === "true");
|
||||||
await s.connect(newSession, true);
|
await s.connect(newSession, true);
|
||||||
setSocket(s);
|
|
||||||
socketRef.current = s;
|
updateState({ socket: s });
|
||||||
|
refs.socketRef.current = s;
|
||||||
|
|
||||||
console.log("[Nakama] WebSocket connected");
|
console.log("[Nakama] WebSocket connected");
|
||||||
|
|
||||||
@@ -126,9 +150,9 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (!matched.match_id) {
|
if (!matched.match_id) {
|
||||||
console.warn("[Nakama] Match rejected by server. Auto-requeueing...");
|
console.warn("[Nakama] Match rejected by server. Auto-requeueing...");
|
||||||
|
|
||||||
if (lastModeRef.current) {
|
if (refs.gameMetadataRef.current) {
|
||||||
try {
|
try {
|
||||||
await joinMatchmaker(lastModeRef.current);
|
await joinMatchmaker(refs.gameMetadataRef.current);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[Nakama] Requeue failed:", e);
|
console.error("[Nakama] Requeue failed:", e);
|
||||||
}
|
}
|
||||||
@@ -141,7 +165,8 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
|||||||
console.log("[Nakama] MATCHED:", matched);
|
console.log("[Nakama] MATCHED:", matched);
|
||||||
try {
|
try {
|
||||||
await s.joinMatch(matched.match_id);
|
await s.joinMatch(matched.match_id);
|
||||||
setMatchId(matched.match_id);
|
updateState({ matchId: matched.match_id });
|
||||||
|
|
||||||
console.log("[Nakama] Auto-joined match:", matched.match_id);
|
console.log("[Nakama] Auto-joined match:", matched.match_id);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Nakama] Failed to join match:", err);
|
console.error("[Nakama] Failed to join match:", err);
|
||||||
@@ -149,21 +174,27 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// LOGOUT
|
||||||
|
// ---------------------------------------
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
// 1) Disconnect socket if present
|
if (refs.socketRef.current) {
|
||||||
if (socketRef.current) {
|
refs.socketRef.current.disconnect(true);
|
||||||
socketRef.current.disconnect(true);
|
|
||||||
console.log("[Nakama] WebSocket disconnected");
|
console.log("[Nakama] WebSocket disconnected");
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.warn("[Nakama] Error while disconnecting socket:", err);
|
console.warn("[Nakama] Error while disconnecting socket:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) Clear state
|
updateState({
|
||||||
setSocket(null);
|
session: null,
|
||||||
socketRef.current = null;
|
socket: null,
|
||||||
setSession(null);
|
matchId: null,
|
||||||
|
matchmakerTicket: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
refs.socketRef.current = null;
|
||||||
|
|
||||||
console.log("[Nakama] Clean logout completed");
|
console.log("[Nakama] Clean logout completed");
|
||||||
}
|
}
|
||||||
@@ -171,30 +202,55 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// MATCHMAKING
|
// MATCHMAKING
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
async function joinMatchmaker(mode: string) {
|
async function joinMatchmaker(gameMetadata: GameMetadataModel) {
|
||||||
const socket = socketRef.current;
|
const socket = refs.socketRef.current;
|
||||||
if (!socket) throw new Error("socket missing");
|
if (!socket) throw new Error("Socket missing");
|
||||||
|
|
||||||
console.log(`[Nakama] Matchmaking... with +mode:"${mode}"`);
|
const { game, mode } = gameMetadata;
|
||||||
|
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(
|
const ticket: MatchmakerTicket = await socket.addMatchmaker(
|
||||||
`*`, // query
|
`*`, // query
|
||||||
2, // min count
|
2, // min count
|
||||||
2, // max count
|
2, // max count
|
||||||
{ mode } // stringProperties
|
{ game, mode }
|
||||||
);
|
);
|
||||||
|
|
||||||
lastModeRef.current = mode;
|
refs.gameMetadataRef.current = { game, mode };
|
||||||
|
|
||||||
|
updateState({ matchmakerTicket: ticket.ticket });
|
||||||
|
|
||||||
return ticket.ticket;
|
return ticket.ticket;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function exitMatchmaker() {
|
||||||
|
const socket = refs.socketRef.current;
|
||||||
|
const { matchmakerTicket } = internal;
|
||||||
|
|
||||||
|
if (!socket) throw new Error("Socket missing");
|
||||||
|
|
||||||
|
if (matchmakerTicket) {
|
||||||
|
await socket.removeMatchmaker(matchmakerTicket);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateState({ matchmakerTicket: null });
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// EXPLICIT MATCH JOIN
|
// EXPLICIT MATCH JOIN
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
async function joinMatch(id: string) {
|
async function joinMatch(id: string) {
|
||||||
if (!socket) throw new Error("socket missing");
|
if (!internal.socket) throw new Error("Socket missing");
|
||||||
await socket.joinMatch(id);
|
|
||||||
setMatchId(id);
|
await internal.socket.joinMatch(id);
|
||||||
|
|
||||||
|
updateState({ matchId: id });
|
||||||
console.log("[Nakama] Joined match", id);
|
console.log("[Nakama] Joined match", id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,17 +258,19 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
|||||||
// MATCH STATE SEND
|
// MATCH STATE SEND
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
function sendMatchData(matchId: string, op: number, data: object) {
|
function sendMatchData(matchId: string, op: number, data: object) {
|
||||||
if (!socket) return;
|
if (!internal.socket) return;
|
||||||
socket.sendMatchState(matchId, op, JSON.stringify(data));
|
|
||||||
|
console.log("[Nakama] Sending match state:", matchId, op, data);
|
||||||
|
internal.socket.sendMatchState(matchId, op, JSON.stringify(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// MATCH DATA LISTENER
|
// MATCH DATA LISTENER
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
function onMatchData(cb: (msg: any) => void) {
|
function onMatchData(cb: (msg: MatchDataModel) => void) {
|
||||||
if (!socket) return;
|
if (!internal.socket) return;
|
||||||
|
|
||||||
socket.onmatchdata = (m: MatchData) => {
|
internal.socket.onmatchdata = (m: MatchData) => {
|
||||||
const decoded = JSON.parse(new TextDecoder().decode(m.data));
|
const decoded = JSON.parse(new TextDecoder().decode(m.data));
|
||||||
cb({
|
cb({
|
||||||
opCode: m.op_code,
|
opCode: m.op_code,
|
||||||
@@ -222,39 +280,47 @@ export function NakamaProvider({ children }: { children: React.ReactNode }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// LEADERBOARD + LIST MATCHES
|
||||||
|
// ---------------------------------------
|
||||||
async function getLeaderboardTop(): Promise<ApiLeaderboardRecordList> {
|
async function getLeaderboardTop(): Promise<ApiLeaderboardRecordList> {
|
||||||
if (!session) return [];
|
if (!internal.session) return [];
|
||||||
|
|
||||||
return await client.listLeaderboardRecords(
|
return await client.listLeaderboardRecords(
|
||||||
session,
|
internal.session,
|
||||||
"tictactoe",
|
"tictactoe",
|
||||||
[],
|
[],
|
||||||
10 // top 10
|
10 // top 10
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function listOpenMatches(): Promise<ApiMatch[]> {
|
async function listOpenMatches(): Promise<ApiMatch[]> {
|
||||||
if (!session) {
|
if (!internal.session) {
|
||||||
console.warn("[Nakama] listOpenMatches called before login");
|
console.warn("[Nakama] listOpenMatches called before login");
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await client.listMatches(session, 10);
|
const result = await client.listMatches(internal.session, 10);
|
||||||
|
|
||||||
console.log("[Nakama] Open matches:", result.matches);
|
console.log("[Nakama] Open matches:", result.matches);
|
||||||
|
|
||||||
return result.matches ?? [];
|
return result.matches ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------
|
||||||
|
// PROVIDER VALUE
|
||||||
|
// ---------------------------------------
|
||||||
return (
|
return (
|
||||||
<NakamaContext.Provider
|
<NakamaContext.Provider
|
||||||
value={{
|
value={{
|
||||||
client,
|
client,
|
||||||
session,
|
session: internal.session,
|
||||||
socket,
|
socket: internal.socket,
|
||||||
matchId,
|
matchId: internal.matchId,
|
||||||
|
|
||||||
loginOrRegister,
|
loginOrRegister,
|
||||||
logout,
|
logout,
|
||||||
joinMatchmaker,
|
joinMatchmaker,
|
||||||
|
exitMatchmaker,
|
||||||
joinMatch,
|
joinMatch,
|
||||||
sendMatchData,
|
sendMatchData,
|
||||||
onMatchData,
|
onMatchData,
|
||||||
@@ -3,7 +3,6 @@ body {
|
|||||||
background: #eaeef3;
|
background: #eaeef3;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding-top: 50px;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #222;
|
color: #222;
|
||||||
}
|
}
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
import React from "react";
|
|
||||||
|
|
||||||
interface BoardProps {
|
|
||||||
board: string[][];
|
|
||||||
turn: number;
|
|
||||||
winner: string | null;
|
|
||||||
players: string[];
|
|
||||||
myUserId: string | null;
|
|
||||||
onCellClick: (row: number, col: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Board({
|
|
||||||
board,
|
|
||||||
turn,
|
|
||||||
winner,
|
|
||||||
players,
|
|
||||||
myUserId,
|
|
||||||
onCellClick,
|
|
||||||
}: BoardProps) {
|
|
||||||
const myIndex = players.indexOf(myUserId ?? "");
|
|
||||||
const gameReady = players.length === 2;
|
|
||||||
|
|
||||||
const mySymbol =
|
|
||||||
myIndex === 0 ? "X" : myIndex === 1 ? "O" : null;
|
|
||||||
|
|
||||||
const opponentSymbol =
|
|
||||||
mySymbol === "X" ? "O" : mySymbol === "O" ? "X" : null;
|
|
||||||
|
|
||||||
const isMyTurn = gameReady && myIndex !== -1 && turn === myIndex;
|
|
||||||
|
|
||||||
// -------------------------------
|
|
||||||
// 🟦 HEADER STATUS FIXED
|
|
||||||
// -------------------------------
|
|
||||||
let status;
|
|
||||||
if (!gameReady) {
|
|
||||||
status = "Waiting for opponent...";
|
|
||||||
} else if (winner) {
|
|
||||||
status = `Winner: ${winner}`;
|
|
||||||
} else if (myIndex === -1) {
|
|
||||||
status = "Spectating";
|
|
||||||
} else {
|
|
||||||
status = isMyTurn ? "Your turn" : "Opponent's turn";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h2 style={{ marginBottom: 8 }}>{status}</h2>
|
|
||||||
|
|
||||||
{gameReady && mySymbol && (
|
|
||||||
<div style={{ marginBottom: 8, fontSize: 14, color: "#666" }}>
|
|
||||||
You: <strong>{mySymbol}</strong> — Opponent: <strong>{opponentSymbol}</strong>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
display: "grid",
|
|
||||||
gridTemplateColumns: "repeat(3, 80px)",
|
|
||||||
gap: "10px",
|
|
||||||
marginTop: "6px",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{board.map((row, rIdx) =>
|
|
||||||
row.map((cell, cIdx) => {
|
|
||||||
const disabled =
|
|
||||||
!!cell ||
|
|
||||||
!!winner ||
|
|
||||||
!gameReady ||
|
|
||||||
myIndex === -1 ||
|
|
||||||
!isMyTurn;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={`${rIdx}-${cIdx}`}
|
|
||||||
style={{
|
|
||||||
width: "80px",
|
|
||||||
height: "80px",
|
|
||||||
fontSize: "2rem",
|
|
||||||
cursor: disabled ? "not-allowed" : "pointer",
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
if (!disabled) onCellClick(rIdx, cIdx);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{cell}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,82 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
|
||||||
import Leaderboard from "./Leaderboard";
|
|
||||||
import { useNakama } from "./providers/NakamaProvider";
|
|
||||||
|
|
||||||
export default function Player({
|
|
||||||
onMatchDataCallback,
|
|
||||||
}: {
|
|
||||||
onMatchDataCallback: (msg: any) => void;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
session,
|
|
||||||
matchId,
|
|
||||||
loginOrRegister,
|
|
||||||
logout,
|
|
||||||
onMatchData,
|
|
||||||
joinMatchmaker,
|
|
||||||
} = useNakama();
|
|
||||||
|
|
||||||
const [username, setUsername] = useState(
|
|
||||||
localStorage.getItem("username") ?? ""
|
|
||||||
);
|
|
||||||
const [selectedMode, setSelectedMode] = useState("classic");
|
|
||||||
|
|
||||||
// ------------------------------------------
|
|
||||||
// CONNECT
|
|
||||||
// ------------------------------------------
|
|
||||||
async function handleConnect() {
|
|
||||||
await loginOrRegister(username);
|
|
||||||
|
|
||||||
// Match data listener
|
|
||||||
onMatchData(onMatchDataCallback);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------
|
|
||||||
// MATCHMAKING
|
|
||||||
// ------------------------------------------
|
|
||||||
async function startQueue(selectedMode: string) {
|
|
||||||
const ticket = await joinMatchmaker(selectedMode);
|
|
||||||
console.log("Queued:", ticket);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
handleConnect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: "20px" }}>
|
|
||||||
{session && !matchId && (
|
|
||||||
<>
|
|
||||||
<h2>Hello, {session.username}</h2>
|
|
||||||
|
|
||||||
{/* Game mode selection */}
|
|
||||||
<label style={{ display: "block", marginTop: "10px" }}>
|
|
||||||
Select Game Mode:
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<select
|
|
||||||
value={selectedMode}
|
|
||||||
onChange={(e) => setSelectedMode(e.target.value)}
|
|
||||||
style={{ padding: "6px", marginBottom: "10px" }}
|
|
||||||
>
|
|
||||||
<option value="classic">Classic</option>
|
|
||||||
<option value="blitz">Blitz</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Join matchmaking */}
|
|
||||||
<button onClick={() => startQueue(selectedMode)}>Join Matchmaking</button>
|
|
||||||
<button onClick={() => logout()}>Logout</button>
|
|
||||||
|
|
||||||
{/*/!* List open matches *!/*/}
|
|
||||||
{/*<MatchList matches={openMatches} />*/}
|
|
||||||
<Leaderboard/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{session && matchId && (
|
|
||||||
<>
|
|
||||||
<h2>Go {session.username}!</h2>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,95 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useNakama } from "./providers/NakamaProvider";
|
|
||||||
import Board from "./Board";
|
|
||||||
import Player from "./Player";
|
|
||||||
// import { Match } from "@heroiclabs/nakama-js";
|
|
||||||
// import MatchList from "./MatchList";
|
|
||||||
|
|
||||||
export default function TicTacToe() {
|
|
||||||
const [board, setBoard] = useState<string[][]>([
|
|
||||||
["", "", ""],
|
|
||||||
["", "", ""],
|
|
||||||
["", "", ""]
|
|
||||||
]);
|
|
||||||
const [turn, setTurn] = useState<number>(0);
|
|
||||||
const [winner, setWinner] = useState<string | null>(null);
|
|
||||||
// const [openMatches, setOpenMatches] = useState<Match[]>([]);
|
|
||||||
const [players, setPlayers] = useState<string[]>([]);
|
|
||||||
|
|
||||||
function onMatchDataCallback(msg: { opCode: number; data: any }) {
|
|
||||||
console.log("[Match Data]", msg);
|
|
||||||
|
|
||||||
if (msg.opCode === 2) {
|
|
||||||
const state = msg.data;
|
|
||||||
console.log("Match state:", state);
|
|
||||||
|
|
||||||
setBoard(state.board);
|
|
||||||
setTurn(state.turn);
|
|
||||||
setWinner(state.winner || null);
|
|
||||||
|
|
||||||
// new:
|
|
||||||
setPlayers(state.players || []);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
|
||||||
onMatchData,
|
|
||||||
sendMatchData,
|
|
||||||
// listOpenMatches,
|
|
||||||
matchId,
|
|
||||||
session,
|
|
||||||
} = useNakama();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onMatchData(onMatchDataCallback);
|
|
||||||
}, [onMatchData]);
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// let active = true;
|
|
||||||
//
|
|
||||||
// async function refreshLoop() {
|
|
||||||
// while (active) {
|
|
||||||
// const matches = await listOpenMatches();
|
|
||||||
// setOpenMatches(matches);
|
|
||||||
//
|
|
||||||
// await new Promise(res => setTimeout(res, 500)); // 0.5s refresh
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// refreshLoop();
|
|
||||||
//
|
|
||||||
// return () => {
|
|
||||||
// active = false;
|
|
||||||
// };
|
|
||||||
// }, [listOpenMatches]);
|
|
||||||
|
|
||||||
// ------------------------------------------
|
|
||||||
// SEND A MOVE
|
|
||||||
// ------------------------------------------
|
|
||||||
function handleCellClick(row: number, col: number) {
|
|
||||||
if (!matchId) return;
|
|
||||||
|
|
||||||
sendMatchData(matchId, 1, { row, col }); // OpMove=1
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h1>Tic Tac Toe Multiplayer</h1>
|
|
||||||
|
|
||||||
<Player
|
|
||||||
onMatchDataCallback={onMatchDataCallback}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{matchId && (
|
|
||||||
<Board
|
|
||||||
board={board}
|
|
||||||
turn={turn}
|
|
||||||
winner={winner}
|
|
||||||
players={players}
|
|
||||||
myUserId={session?.user_id ?? null}
|
|
||||||
onCellClick={handleCellClick}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
91
src/utils/haikus.ts
Normal file
91
src/utils/haikus.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
export const HAIKUS: string[][] = [
|
||||||
|
// HAIKU STORY SET 1 — The Only Winning Move Is No Move
|
||||||
|
[
|
||||||
|
"Silence fills the board.",
|
||||||
|
"Two minds waiting, both flawless.",
|
||||||
|
"Equilibrium.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Perfect strategies",
|
||||||
|
"Cancel out in quiet draws.",
|
||||||
|
"Stillness holds the key.",
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Victory fades out,",
|
||||||
|
"When both players see the truth:",
|
||||||
|
"No move is the winning move.",
|
||||||
|
],
|
||||||
|
|
||||||
|
// // HAIKU STORY SET 2 — AI & Game Theory
|
||||||
|
// [
|
||||||
|
// "Grids bend under thought.",
|
||||||
|
// "Algorithms watch patterns.",
|
||||||
|
// "The future decides.",
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "Zeroes read the board.",
|
||||||
|
// "Minimax breathes in the dark.",
|
||||||
|
// "Loss is calculated.",
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "Two perfect AIs",
|
||||||
|
// "Stare across a tiny world.",
|
||||||
|
// "Neither one can win.",
|
||||||
|
// ],
|
||||||
|
//
|
||||||
|
// // HAIKU STORY SET 3 — Players Becoming Machines
|
||||||
|
// [
|
||||||
|
// "Hands learn old rhythms.",
|
||||||
|
// "Humans imitate the code.",
|
||||||
|
// "We evolve to think.",
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "Soft neon whispers,",
|
||||||
|
// "The grid calls for your next move.",
|
||||||
|
// "Time waits for no one.",
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "Your choices echo.",
|
||||||
|
// "Small decisions shape the board.",
|
||||||
|
// "You shape the story.",
|
||||||
|
// ],
|
||||||
|
//
|
||||||
|
// // HAIKU STORY SET 4 — Solving the Game
|
||||||
|
// [
|
||||||
|
// "Every path explored,",
|
||||||
|
// "Every outcome known too well.",
|
||||||
|
// "Beauty in the bones.",
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "Corners dream of acts,",
|
||||||
|
// "Center knows its destiny.",
|
||||||
|
// "Balance is the law.",
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "Three lines cross in fate.",
|
||||||
|
// "Nine spaces hold nine futures.",
|
||||||
|
// "All end in a draw.",
|
||||||
|
// ],
|
||||||
|
//
|
||||||
|
// // HAIKU STORY SET 5 — Existential Tic-Tac-Toe
|
||||||
|
// [
|
||||||
|
// "The board is a mirror.",
|
||||||
|
// "It reflects your quiet mind.",
|
||||||
|
// "Win by understanding.",
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "Nothing left to prove.",
|
||||||
|
// "The shape of thought is perfect.",
|
||||||
|
// "The game simply is.",
|
||||||
|
// ],
|
||||||
|
// [
|
||||||
|
// "A small universe,",
|
||||||
|
// "Filled with silent decisions.",
|
||||||
|
// "Meaning in the moves.",
|
||||||
|
// ],
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function getHaiku() {
|
||||||
|
const idx = Math.floor(Math.random() * HAIKUS.length);
|
||||||
|
return HAIKUS[idx];
|
||||||
|
}
|
||||||
5
src/vite-env.d.ts
vendored
5
src/vite-env.d.ts
vendored
@@ -1,7 +1,10 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
readonly VITE_WS_BASE_URL: string;
|
readonly VITE_WS_HOST: string;
|
||||||
|
readonly VITE_WS_PORT: string;
|
||||||
|
readonly VITE_WS_SKEY: string;
|
||||||
|
readonly VITE_WS_SSL: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ImportMeta {
|
interface ImportMeta {
|
||||||
|
|||||||
Reference in New Issue
Block a user