create-usion-game 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/index.js +135 -0
- package/package.json +30 -0
- package/templates/base/next.config.js +7 -0
- package/templates/base/tsconfig.json +22 -0
- package/templates/real-time/.env.example +14 -0
- package/templates/real-time/README.md +68 -0
- package/templates/real-time/app/layout.tsx +12 -0
- package/templates/real-time/app/page.tsx +255 -0
- package/templates/real-time/lib/constants.ts +8 -0
- package/templates/real-time/lib/types.ts +23 -0
- package/templates/real-time/package.json +24 -0
- package/templates/real-time/server/game-logic.js +55 -0
- package/templates/real-time/server.js +146 -0
- package/templates/turn-based/.env.example +5 -0
- package/templates/turn-based/README.md +41 -0
- package/templates/turn-based/app/layout.tsx +12 -0
- package/templates/turn-based/app/page.tsx +322 -0
- package/templates/turn-based/lib/constants.ts +13 -0
- package/templates/turn-based/lib/types.ts +16 -0
- package/templates/turn-based/package.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# create-usion-game
|
|
2
|
+
|
|
3
|
+
Scaffold a new multiplayer game for the [Usion](https://usions.com) platform.
|
|
4
|
+
|
|
5
|
+
## Usage
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx create-usion-game my-game
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Follow the interactive prompts to choose:
|
|
12
|
+
|
|
13
|
+
1. **Game type** — Turn-based (platform mode) or Real-time (direct mode)
|
|
14
|
+
2. **Max players** — 2, 2-4, or 2-8
|
|
15
|
+
3. **Server port**
|
|
16
|
+
|
|
17
|
+
## What You Get
|
|
18
|
+
|
|
19
|
+
### Turn-based template (platform mode)
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
my-game/
|
|
23
|
+
├── app/page.tsx # Game UI (tic-tac-toe starter)
|
|
24
|
+
├── lib/types.ts # Game state types
|
|
25
|
+
├── lib/constants.ts # Board size, win conditions
|
|
26
|
+
├── package.json # Next.js + @usion/sdk
|
|
27
|
+
└── README.md
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Games relay through Usion's backend via Socket.IO. No server needed.
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
cd my-game && npm install && npm run dev
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Real-time template (direct mode)
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
my-game/
|
|
40
|
+
├── server.js # Game server (UsionGameServer)
|
|
41
|
+
├── server/game-logic.js # Pure game rules (testable)
|
|
42
|
+
├── app/page.tsx # Canvas client
|
|
43
|
+
├── lib/types.ts
|
|
44
|
+
├── lib/constants.ts
|
|
45
|
+
├── .env.example
|
|
46
|
+
├── package.json # Next.js + @usion/game-server
|
|
47
|
+
└── README.md
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You run your own WebSocket server. Token validation and result submission are handled by `@usion/game-server`.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
cd my-game && npm install
|
|
54
|
+
cp .env.example .env # Add your SERVICE_ID and secret
|
|
55
|
+
npm run dev:server # Start game server
|
|
56
|
+
npm run dev # Start client (in another terminal)
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Related Packages
|
|
60
|
+
|
|
61
|
+
- [`@usion/sdk`](https://www.npmjs.com/package/@usion/sdk) — Client SDK for mini-apps and games
|
|
62
|
+
- [`@usion/game-server`](https://www.npmjs.com/package/@usion/game-server) — Server SDK for direct-mode games
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* create-usion-game — Scaffold a new Usion multiplayer game project.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* npx create-usion-game my-game
|
|
8
|
+
* node packages/create-usion-game/index.js my-game
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createInterface } from 'readline';
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, statSync, copyFileSync } from 'fs';
|
|
13
|
+
import { join, dirname, resolve } from 'path';
|
|
14
|
+
import { fileURLToPath } from 'url';
|
|
15
|
+
|
|
16
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
17
|
+
const __dirname = dirname(__filename);
|
|
18
|
+
const TEMPLATES_DIR = join(__dirname, 'templates');
|
|
19
|
+
|
|
20
|
+
// ─── Helpers ───────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
function prompt(rl, question) {
|
|
23
|
+
return new Promise((resolve) => rl.question(question, resolve));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function choose(rl, question, options) {
|
|
27
|
+
console.log(`\n${question}`);
|
|
28
|
+
options.forEach((opt, i) => {
|
|
29
|
+
console.log(` ${i + 1}) ${opt.label}${opt.description ? ` — ${opt.description}` : ''}`);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
while (true) {
|
|
33
|
+
const answer = await prompt(rl, `\nChoice (1-${options.length}): `);
|
|
34
|
+
const idx = parseInt(answer, 10) - 1;
|
|
35
|
+
if (idx >= 0 && idx < options.length) return options[idx].value;
|
|
36
|
+
console.log(` Please enter a number between 1 and ${options.length}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function copyDirRecursive(src, dest, replacements) {
|
|
41
|
+
if (!existsSync(dest)) mkdirSync(dest, { recursive: true });
|
|
42
|
+
|
|
43
|
+
for (const entry of readdirSync(src)) {
|
|
44
|
+
const srcPath = join(src, entry);
|
|
45
|
+
const destPath = join(dest, entry);
|
|
46
|
+
const stat = statSync(srcPath);
|
|
47
|
+
|
|
48
|
+
if (stat.isDirectory()) {
|
|
49
|
+
copyDirRecursive(srcPath, destPath, replacements);
|
|
50
|
+
} else {
|
|
51
|
+
let content = readFileSync(srcPath, 'utf-8');
|
|
52
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
53
|
+
content = content.replaceAll(key, value);
|
|
54
|
+
}
|
|
55
|
+
writeFileSync(destPath, content);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Main ──────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
async function main() {
|
|
63
|
+
const projectName = process.argv[2];
|
|
64
|
+
|
|
65
|
+
if (!projectName) {
|
|
66
|
+
console.log('\nUsage: create-usion-game <project-name>\n');
|
|
67
|
+
console.log('Example:');
|
|
68
|
+
console.log(' npx create-usion-game my-game\n');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const projectDir = resolve(process.cwd(), projectName);
|
|
73
|
+
|
|
74
|
+
if (existsSync(projectDir)) {
|
|
75
|
+
console.error(`\nError: Directory "${projectName}" already exists.\n`);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
80
|
+
|
|
81
|
+
console.log(`\n Creating a new Usion game: ${projectName}\n`);
|
|
82
|
+
|
|
83
|
+
const gameType = await choose(rl, 'What type of game?', [
|
|
84
|
+
{ label: 'Turn-based', value: 'turn-based', description: 'Board games, card games (platform mode)' },
|
|
85
|
+
{ label: 'Real-time', value: 'real-time', description: 'Arena, racing, action (direct mode + server)' },
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const maxPlayers = await choose(rl, 'Max players per room?', [
|
|
89
|
+
{ label: '2 players', value: '2' },
|
|
90
|
+
{ label: '2-4 players', value: '4' },
|
|
91
|
+
{ label: '2-8 players', value: '8' },
|
|
92
|
+
]);
|
|
93
|
+
|
|
94
|
+
const port = await prompt(rl, '\nServer port (default 3005): ');
|
|
95
|
+
const finalPort = port.trim() || '3005';
|
|
96
|
+
|
|
97
|
+
rl.close();
|
|
98
|
+
|
|
99
|
+
console.log(`\n Scaffolding project...\n`);
|
|
100
|
+
|
|
101
|
+
const replacements = {
|
|
102
|
+
'{{PROJECT_NAME}}': projectName,
|
|
103
|
+
'{{MAX_PLAYERS}}': maxPlayers,
|
|
104
|
+
'{{PORT}}': finalPort,
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Copy base files first
|
|
108
|
+
const baseDir = join(TEMPLATES_DIR, 'base');
|
|
109
|
+
if (existsSync(baseDir)) {
|
|
110
|
+
copyDirRecursive(baseDir, projectDir, replacements);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Copy template-specific files (overrides base if conflict)
|
|
114
|
+
const templateDir = join(TEMPLATES_DIR, gameType);
|
|
115
|
+
copyDirRecursive(templateDir, projectDir, replacements);
|
|
116
|
+
|
|
117
|
+
// Success message
|
|
118
|
+
console.log(` Done! Created ${projectName}\n`);
|
|
119
|
+
console.log(' Next steps:\n');
|
|
120
|
+
console.log(` cd ${projectName}`);
|
|
121
|
+
console.log(' npm install');
|
|
122
|
+
if (gameType === 'real-time') {
|
|
123
|
+
console.log(' # Configure .env with your USION_SHARED_SECRET and SERVICE_ID');
|
|
124
|
+
console.log(' npm run dev:server # Start game server');
|
|
125
|
+
console.log(' npm run dev # Start Next.js client (in another terminal)');
|
|
126
|
+
} else {
|
|
127
|
+
console.log(' npm run dev # Start Next.js dev server');
|
|
128
|
+
}
|
|
129
|
+
console.log('\n Open your game in the Usion app to test.\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
main().catch((err) => {
|
|
133
|
+
console.error(err);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-usion-game",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Create a new Usion multiplayer game project",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"create-usion-game": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.js",
|
|
11
|
+
"templates/"
|
|
12
|
+
],
|
|
13
|
+
"keywords": [
|
|
14
|
+
"usion",
|
|
15
|
+
"game",
|
|
16
|
+
"multiplayer",
|
|
17
|
+
"create",
|
|
18
|
+
"scaffold",
|
|
19
|
+
"template"
|
|
20
|
+
],
|
|
21
|
+
"homepage": "https://github.com/usion-platform/create-usion-game#readme",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/usion-platform/create-usion-game.git"
|
|
25
|
+
},
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/usion-platform/create-usion-game/issues"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "es2017",
|
|
4
|
+
"lib": ["dom", "dom.iterable", "esnext"],
|
|
5
|
+
"allowJs": true,
|
|
6
|
+
"skipLibCheck": true,
|
|
7
|
+
"strict": false,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"module": "esnext",
|
|
11
|
+
"moduleResolution": "bundler",
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"jsx": "preserve",
|
|
15
|
+
"incremental": true,
|
|
16
|
+
"paths": {
|
|
17
|
+
"@/*": ["./*"]
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
|
|
21
|
+
"exclude": ["node_modules", "server", "server.js"]
|
|
22
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Usion API URL
|
|
2
|
+
API_URL=http://localhost:8089
|
|
3
|
+
|
|
4
|
+
# Your service ID from the Usion platform
|
|
5
|
+
SERVICE_ID=your-service-id
|
|
6
|
+
|
|
7
|
+
# HMAC shared secret (from service registration)
|
|
8
|
+
USION_SHARED_SECRET=your-shared-secret
|
|
9
|
+
|
|
10
|
+
# Signing key ID (from service realtime.signing.key_id)
|
|
11
|
+
USION_KEY_ID=default
|
|
12
|
+
|
|
13
|
+
# Server port
|
|
14
|
+
PORT={{PORT}}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
A real-time multiplayer game built on the Usion platform with direct-mode connection.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
|
|
10
|
+
# Configure your environment
|
|
11
|
+
cp .env.example .env
|
|
12
|
+
# Edit .env with your SERVICE_ID and USION_SHARED_SECRET
|
|
13
|
+
|
|
14
|
+
# Start the game server (WebSocket)
|
|
15
|
+
npm run dev:server
|
|
16
|
+
|
|
17
|
+
# In another terminal, start the Next.js client
|
|
18
|
+
npm run dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Architecture
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
Usion App ──token──> Your Server (server.js)
|
|
25
|
+
│ │
|
|
26
|
+
└── WebSocket ───────┘
|
|
27
|
+
(direct mode)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **Server** (`server.js`): Runs `@usion/game-server`, validates RS256 tokens, manages rooms
|
|
31
|
+
- **Game Logic** (`server/game-logic.js`): Pure game rules — no networking, testable
|
|
32
|
+
- **Client** (`app/page.tsx`): Canvas rendering, input handling, SDK connection
|
|
33
|
+
|
|
34
|
+
## Key Files
|
|
35
|
+
|
|
36
|
+
| File | Purpose |
|
|
37
|
+
|------|---------|
|
|
38
|
+
| `server.js` | UsionGameServer setup with event handlers |
|
|
39
|
+
| `server/game-logic.js` | Pure game logic (movement, collision, scoring) |
|
|
40
|
+
| `app/page.tsx` | Client-side rendering and input |
|
|
41
|
+
| `lib/types.ts` | Shared type definitions |
|
|
42
|
+
| `lib/constants.ts` | Arena size, speed, colors |
|
|
43
|
+
|
|
44
|
+
## How It Works
|
|
45
|
+
|
|
46
|
+
1. Player opens game in Usion app
|
|
47
|
+
2. Usion backend issues RS256 access token
|
|
48
|
+
3. Client connects to your server via WebSocket with token
|
|
49
|
+
4. `@usion/game-server` validates token automatically
|
|
50
|
+
5. Server manages room lifecycle and broadcasts state
|
|
51
|
+
6. Client sends real-time input, server validates and relays
|
|
52
|
+
|
|
53
|
+
## Customization
|
|
54
|
+
|
|
55
|
+
1. **Game rules**: Edit `server/game-logic.js` — add your own `createPlayer`, `applyInput`, scoring
|
|
56
|
+
2. **Server events**: Edit `server.js` — add `onAction('your-action', ...)` handlers
|
|
57
|
+
3. **Client rendering**: Edit `app/page.tsx` — replace canvas drawing with your visuals
|
|
58
|
+
4. **Constants**: Edit `lib/constants.ts` — arena size, speed, colors
|
|
59
|
+
|
|
60
|
+
## Environment Variables
|
|
61
|
+
|
|
62
|
+
| Variable | Description |
|
|
63
|
+
|----------|-------------|
|
|
64
|
+
| `SERVICE_ID` | Your game's service ID on Usion |
|
|
65
|
+
| `USION_SHARED_SECRET` | HMAC secret for result submission |
|
|
66
|
+
| `USION_KEY_ID` | Signing key ID (default: 'default') |
|
|
67
|
+
| `API_URL` | Usion backend URL |
|
|
68
|
+
| `PORT` | Server port |
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const metadata = {
|
|
2
|
+
title: '{{PROJECT_NAME}}',
|
|
3
|
+
description: 'Usion real-time multiplayer game',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<html lang="en">
|
|
9
|
+
<body>{children}</body>
|
|
10
|
+
</html>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* {{PROJECT_NAME}} — Real-Time Game Client (Direct Mode)
|
|
5
|
+
*
|
|
6
|
+
* This client connects directly to your game server's WebSocket.
|
|
7
|
+
* The Usion SDK handles token acquisition and connection setup.
|
|
8
|
+
*
|
|
9
|
+
* Flow: Usion.init → game.connectDirect → game.join → game.realtime → game.onRealtime
|
|
10
|
+
*
|
|
11
|
+
* Replace the canvas rendering with your own game visuals.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import Script from 'next/script';
|
|
15
|
+
|
|
16
|
+
export default function GamePage() {
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
<Script src="/usion-sdk.js" strategy="beforeInteractive" />
|
|
20
|
+
|
|
21
|
+
<Script id="game-logic" strategy="afterInteractive">{`
|
|
22
|
+
(function() {
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const ARENA_W = 800;
|
|
26
|
+
const ARENA_H = 600;
|
|
27
|
+
const PLAYER_R = 15;
|
|
28
|
+
const MOVE_SPEED = 5;
|
|
29
|
+
const INPUT_RATE_MS = 50;
|
|
30
|
+
const COLORS = ['#4FC3F7','#FF7043','#66BB6A','#AB47BC','#FFCA28','#EF5350'];
|
|
31
|
+
|
|
32
|
+
const state = {
|
|
33
|
+
phase: 'connecting',
|
|
34
|
+
myId: null,
|
|
35
|
+
players: {},
|
|
36
|
+
keys: { up: false, down: false, left: false, right: false },
|
|
37
|
+
error: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
let canvas, ctx;
|
|
41
|
+
let lastInputSent = 0;
|
|
42
|
+
|
|
43
|
+
// ─── Input ───────────────────────────────────────────────
|
|
44
|
+
const KEY_MAP = {
|
|
45
|
+
ArrowUp: 'up', ArrowDown: 'down', ArrowLeft: 'left', ArrowRight: 'right',
|
|
46
|
+
w: 'up', s: 'down', a: 'left', d: 'right',
|
|
47
|
+
W: 'up', S: 'down', A: 'left', D: 'right',
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
window.addEventListener('keydown', function(e) {
|
|
51
|
+
const dir = KEY_MAP[e.key];
|
|
52
|
+
if (dir) { state.keys[dir] = true; e.preventDefault(); }
|
|
53
|
+
});
|
|
54
|
+
window.addEventListener('keyup', function(e) {
|
|
55
|
+
const dir = KEY_MAP[e.key];
|
|
56
|
+
if (dir) { state.keys[dir] = false; e.preventDefault(); }
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ─── Render Loop ─────────────────────────────────────────
|
|
60
|
+
function render() {
|
|
61
|
+
if (!ctx) return;
|
|
62
|
+
ctx.fillStyle = '#0a0a0a';
|
|
63
|
+
ctx.fillRect(0, 0, ARENA_W, ARENA_H);
|
|
64
|
+
|
|
65
|
+
// Draw grid
|
|
66
|
+
ctx.strokeStyle = '#1a1a1a';
|
|
67
|
+
ctx.lineWidth = 1;
|
|
68
|
+
for (let x = 0; x < ARENA_W; x += 40) {
|
|
69
|
+
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, ARENA_H); ctx.stroke();
|
|
70
|
+
}
|
|
71
|
+
for (let y = 0; y < ARENA_H; y += 40) {
|
|
72
|
+
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(ARENA_W, y); ctx.stroke();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Draw players
|
|
76
|
+
const players = Object.values(state.players);
|
|
77
|
+
players.forEach(function(p, i) {
|
|
78
|
+
ctx.fillStyle = p.color || COLORS[i % COLORS.length];
|
|
79
|
+
ctx.beginPath();
|
|
80
|
+
ctx.arc(p.x, p.y, PLAYER_R, 0, Math.PI * 2);
|
|
81
|
+
ctx.fill();
|
|
82
|
+
|
|
83
|
+
// Name label
|
|
84
|
+
ctx.fillStyle = '#fff';
|
|
85
|
+
ctx.font = '11px system-ui';
|
|
86
|
+
ctx.textAlign = 'center';
|
|
87
|
+
ctx.fillText(p.name || 'Player', p.x, p.y - PLAYER_R - 6);
|
|
88
|
+
|
|
89
|
+
// "You" indicator
|
|
90
|
+
if (p.id === state.myId) {
|
|
91
|
+
ctx.strokeStyle = '#fff';
|
|
92
|
+
ctx.lineWidth = 2;
|
|
93
|
+
ctx.beginPath();
|
|
94
|
+
ctx.arc(p.x, p.y, PLAYER_R + 4, 0, Math.PI * 2);
|
|
95
|
+
ctx.stroke();
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Phase overlay
|
|
100
|
+
if (state.phase === 'waiting') {
|
|
101
|
+
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
102
|
+
ctx.fillRect(0, 0, ARENA_W, ARENA_H);
|
|
103
|
+
ctx.fillStyle = '#fff';
|
|
104
|
+
ctx.font = '24px system-ui';
|
|
105
|
+
ctx.textAlign = 'center';
|
|
106
|
+
ctx.fillText('Waiting for opponent...', ARENA_W / 2, ARENA_H / 2);
|
|
107
|
+
} else if (state.phase === 'finished') {
|
|
108
|
+
ctx.fillStyle = 'rgba(0,0,0,0.7)';
|
|
109
|
+
ctx.fillRect(0, 0, ARENA_W, ARENA_H);
|
|
110
|
+
ctx.fillStyle = '#fff';
|
|
111
|
+
ctx.font = '28px system-ui';
|
|
112
|
+
ctx.textAlign = 'center';
|
|
113
|
+
ctx.fillText('Game Over', ARENA_W / 2, ARENA_H / 2 - 20);
|
|
114
|
+
ctx.font = '16px system-ui';
|
|
115
|
+
ctx.fillText('Tap rematch to play again', ARENA_W / 2, ARENA_H / 2 + 20);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Error
|
|
119
|
+
if (state.error) {
|
|
120
|
+
ctx.fillStyle = '#ff4444';
|
|
121
|
+
ctx.font = '13px system-ui';
|
|
122
|
+
ctx.textAlign = 'center';
|
|
123
|
+
ctx.fillText(state.error, ARENA_W / 2, 20);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Game Loop ───────────────────────────────────────────
|
|
128
|
+
function gameLoop() {
|
|
129
|
+
const now = Date.now();
|
|
130
|
+
|
|
131
|
+
// Send input at fixed rate
|
|
132
|
+
if (state.phase === 'playing' && now - lastInputSent >= INPUT_RATE_MS) {
|
|
133
|
+
const dx = (state.keys.right ? 1 : 0) - (state.keys.left ? 1 : 0);
|
|
134
|
+
const dy = (state.keys.down ? 1 : 0) - (state.keys.up ? 1 : 0);
|
|
135
|
+
|
|
136
|
+
if (dx !== 0 || dy !== 0) {
|
|
137
|
+
Usion.game.realtime('move', { dx: dx, dy: dy });
|
|
138
|
+
|
|
139
|
+
// Local prediction
|
|
140
|
+
var me = state.players[state.myId];
|
|
141
|
+
if (me) {
|
|
142
|
+
var len = Math.sqrt(dx*dx + dy*dy);
|
|
143
|
+
me.x = Math.max(PLAYER_R, Math.min(ARENA_W - PLAYER_R, me.x + (dx/len)*MOVE_SPEED));
|
|
144
|
+
me.y = Math.max(PLAYER_R, Math.min(ARENA_H - PLAYER_R, me.y + (dy/len)*MOVE_SPEED));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
lastInputSent = now;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
render();
|
|
152
|
+
requestAnimationFrame(gameLoop);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ─── SDK Init ────────────────────────────────────────────
|
|
156
|
+
Usion.init(function(config) {
|
|
157
|
+
state.myId = config.userId;
|
|
158
|
+
|
|
159
|
+
Usion.game.connectDirect()
|
|
160
|
+
.then(function() { return Usion.game.join(config.roomId); })
|
|
161
|
+
.then(function(joinData) {
|
|
162
|
+
state.phase = 'waiting';
|
|
163
|
+
render();
|
|
164
|
+
})
|
|
165
|
+
.catch(function(err) {
|
|
166
|
+
state.error = 'Connection failed: ' + err.message;
|
|
167
|
+
render();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Another player joined
|
|
171
|
+
Usion.game.onPlayerJoined(function(data) {
|
|
172
|
+
state.phase = 'playing';
|
|
173
|
+
render();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// State snapshot from server
|
|
177
|
+
Usion.game.onStateUpdate(function(data) {
|
|
178
|
+
var gs = data.game_state || data.payload?.game_state || data;
|
|
179
|
+
if (gs.players) state.players = gs.players;
|
|
180
|
+
if (gs.phase) state.phase = gs.phase;
|
|
181
|
+
render();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Real-time position update from another player
|
|
185
|
+
Usion.game.onRealtime(function(data) {
|
|
186
|
+
var d = data.action_data || data;
|
|
187
|
+
if (d.player_id && d.x !== undefined) {
|
|
188
|
+
if (state.players[d.player_id]) {
|
|
189
|
+
state.players[d.player_id].x = d.x;
|
|
190
|
+
state.players[d.player_id].y = d.y;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
Usion.game.onGameFinished(function(data) {
|
|
196
|
+
state.phase = 'finished';
|
|
197
|
+
render();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
Usion.game.onPlayerLeft(function(data) {
|
|
201
|
+
delete state.players[data.player_id];
|
|
202
|
+
render();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
Usion.game.onError(function(data) {
|
|
206
|
+
state.error = data.message || 'Error occurred';
|
|
207
|
+
render();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
Usion.game.onDisconnect(function() {
|
|
211
|
+
state.error = 'Disconnected';
|
|
212
|
+
render();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
Usion.game.onReconnect(function() {
|
|
216
|
+
state.error = null;
|
|
217
|
+
render();
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ─── Canvas Setup ────────────────────────────────────────
|
|
222
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
223
|
+
var app = document.getElementById('app');
|
|
224
|
+
app.innerHTML =
|
|
225
|
+
'<canvas id="game-canvas" width="' + ARENA_W + '" height="' + ARENA_H + '"></canvas>';
|
|
226
|
+
canvas = document.getElementById('game-canvas');
|
|
227
|
+
ctx = canvas.getContext('2d');
|
|
228
|
+
gameLoop();
|
|
229
|
+
});
|
|
230
|
+
})();
|
|
231
|
+
`}</Script>
|
|
232
|
+
|
|
233
|
+
<div id="app" />
|
|
234
|
+
|
|
235
|
+
<style jsx global>{`
|
|
236
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
237
|
+
html, body { height: 100%; background: #000; overflow: hidden; }
|
|
238
|
+
|
|
239
|
+
#app {
|
|
240
|
+
display: flex;
|
|
241
|
+
align-items: center;
|
|
242
|
+
justify-content: center;
|
|
243
|
+
min-height: 100vh;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
canvas {
|
|
247
|
+
max-width: 100vw;
|
|
248
|
+
max-height: 100vh;
|
|
249
|
+
border: 1px solid #222;
|
|
250
|
+
border-radius: 4px;
|
|
251
|
+
}
|
|
252
|
+
`}</style>
|
|
253
|
+
</>
|
|
254
|
+
);
|
|
255
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Game constants */
|
|
2
|
+
|
|
3
|
+
export const ARENA_WIDTH = 800;
|
|
4
|
+
export const ARENA_HEIGHT = 600;
|
|
5
|
+
export const PLAYER_RADIUS = 15;
|
|
6
|
+
export const MOVE_SPEED = 5;
|
|
7
|
+
export const MAX_PLAYERS = {{MAX_PLAYERS}};
|
|
8
|
+
export const COLORS = ['#4FC3F7', '#FF7043', '#66BB6A', '#AB47BC', '#FFCA28', '#EF5350', '#26C6DA', '#8D6E63'];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Game-specific types */
|
|
2
|
+
|
|
3
|
+
export interface PlayerState {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
score: number;
|
|
9
|
+
color: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GameState {
|
|
13
|
+
players: Record<string, PlayerState>;
|
|
14
|
+
phase: 'waiting' | 'playing' | 'finished';
|
|
15
|
+
winner: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type InputData = {
|
|
19
|
+
x: number;
|
|
20
|
+
y: number;
|
|
21
|
+
dx: number;
|
|
22
|
+
dy: number;
|
|
23
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev -p {{PORT}} -H 0.0.0.0",
|
|
8
|
+
"dev:server": "node server.js",
|
|
9
|
+
"build": "next build",
|
|
10
|
+
"start": "node server.js",
|
|
11
|
+
"lint": "next lint"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@usion/game-server": "^1.0.0",
|
|
15
|
+
"next": "^14.2.0",
|
|
16
|
+
"react": "^18.2.0",
|
|
17
|
+
"react-dom": "^18.2.0"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/node": "^20.0.0",
|
|
21
|
+
"@types/react": "^18.2.0",
|
|
22
|
+
"typescript": "^5.3.0"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure game logic — no networking, no side effects.
|
|
3
|
+
*
|
|
4
|
+
* This file is importable and testable independently.
|
|
5
|
+
* Replace these functions with your own game rules.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ARENA_WIDTH = 800;
|
|
9
|
+
const ARENA_HEIGHT = 600;
|
|
10
|
+
const PLAYER_RADIUS = 15;
|
|
11
|
+
const MOVE_SPEED = 5;
|
|
12
|
+
const COLORS = ['#4FC3F7', '#FF7043', '#66BB6A', '#AB47BC', '#FFCA28', '#EF5350', '#26C6DA', '#8D6E63'];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Create initial state for a new player.
|
|
16
|
+
*/
|
|
17
|
+
export function createPlayer(userId, name, index) {
|
|
18
|
+
return {
|
|
19
|
+
id: userId,
|
|
20
|
+
name: name,
|
|
21
|
+
x: 100 + (index * 150) % (ARENA_WIDTH - 200),
|
|
22
|
+
y: 100 + Math.floor(index / 4) * 150,
|
|
23
|
+
score: 0,
|
|
24
|
+
color: COLORS[index % COLORS.length],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Apply a movement input to a player.
|
|
30
|
+
* Returns the updated player object.
|
|
31
|
+
*/
|
|
32
|
+
export function applyInput(player, input) {
|
|
33
|
+
const dx = typeof input.dx === 'number' ? input.dx : 0;
|
|
34
|
+
const dy = typeof input.dy === 'number' ? input.dy : 0;
|
|
35
|
+
|
|
36
|
+
// Normalize diagonal movement
|
|
37
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
38
|
+
const ndx = len > 0 ? (dx / len) * MOVE_SPEED : 0;
|
|
39
|
+
const ndy = len > 0 ? (dy / len) * MOVE_SPEED : 0;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
...player,
|
|
43
|
+
x: Math.max(PLAYER_RADIUS, Math.min(ARENA_WIDTH - PLAYER_RADIUS, player.x + ndx)),
|
|
44
|
+
y: Math.max(PLAYER_RADIUS, Math.min(ARENA_HEIGHT - PLAYER_RADIUS, player.y + ndy)),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check if two players are colliding.
|
|
50
|
+
*/
|
|
51
|
+
export function checkCollision(p1, p2) {
|
|
52
|
+
const dx = p1.x - p2.x;
|
|
53
|
+
const dy = p1.y - p2.y;
|
|
54
|
+
return Math.sqrt(dx * dx + dy * dy) < PLAYER_RADIUS * 2;
|
|
55
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {{PROJECT_NAME}} — Game Server (Direct Mode)
|
|
3
|
+
*
|
|
4
|
+
* This server handles WebSocket connections from Usion game clients.
|
|
5
|
+
* Token validation and result submission are handled automatically
|
|
6
|
+
* by @usion/game-server.
|
|
7
|
+
*
|
|
8
|
+
* Start: node server.js
|
|
9
|
+
*/
|
|
10
|
+
import { UsionGameServer } from '@usion/game-server';
|
|
11
|
+
import { createPlayer, applyInput } from './server/game-logic.js';
|
|
12
|
+
|
|
13
|
+
const PORT = Number(process.env.PORT || {{PORT}});
|
|
14
|
+
const MAX_PLAYERS = {{MAX_PLAYERS}};
|
|
15
|
+
|
|
16
|
+
const server = new UsionGameServer({
|
|
17
|
+
serviceId: process.env.SERVICE_ID || 'my-game',
|
|
18
|
+
sharedSecret: process.env.USION_SHARED_SECRET || 'dev-secret',
|
|
19
|
+
keyId: process.env.USION_KEY_ID || 'default',
|
|
20
|
+
apiUrl: process.env.API_URL || 'http://localhost:8089',
|
|
21
|
+
port: PORT,
|
|
22
|
+
maxPlayersPerRoom: MAX_PLAYERS,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ─── Player Join ─────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
server.onPlayerJoin((player, room) => {
|
|
28
|
+
console.log(`[${room.id}] Player joined: ${player.name} (${player.id})`);
|
|
29
|
+
|
|
30
|
+
// Create game player state
|
|
31
|
+
const playerState = createPlayer(player.id, player.name, room.playerCount - 1);
|
|
32
|
+
|
|
33
|
+
// Add to game state
|
|
34
|
+
const players = room.getState().players || {};
|
|
35
|
+
players[player.id] = playerState;
|
|
36
|
+
room.setState({ players, phase: 'waiting' });
|
|
37
|
+
|
|
38
|
+
// Start game when enough players join
|
|
39
|
+
if (room.playerCount >= 2) {
|
|
40
|
+
room.setState({ phase: 'playing' });
|
|
41
|
+
room.broadcast('state_snapshot', {
|
|
42
|
+
room_id: room.id,
|
|
43
|
+
game_state: room.getState(),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ─── Player Leave ────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
server.onPlayerLeave((player, room) => {
|
|
51
|
+
console.log(`[${room.id}] Player left: ${player.name}`);
|
|
52
|
+
|
|
53
|
+
const state = room.getState();
|
|
54
|
+
if (state.players) {
|
|
55
|
+
delete state.players[player.id];
|
|
56
|
+
room.setState({ players: state.players });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// If only one player left, they win
|
|
60
|
+
if (room.playerCount === 1 && state.phase === 'playing') {
|
|
61
|
+
const winnerId = room.playerIds[0];
|
|
62
|
+
room.setState({ phase: 'finished', winner: winnerId });
|
|
63
|
+
room.broadcast('match_end', {
|
|
64
|
+
room_id: room.id,
|
|
65
|
+
winner_ids: [winnerId],
|
|
66
|
+
reason: 'opponent_left',
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Submit result to Usion backend
|
|
70
|
+
server.submitResult(room.id, {
|
|
71
|
+
winnerIds: [winnerId],
|
|
72
|
+
reason: 'opponent_left',
|
|
73
|
+
}).catch(err => console.error('Result submission failed:', err.message));
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ─── Real-time Input (fire-and-forget, high frequency) ───────
|
|
78
|
+
|
|
79
|
+
server.onRealtime('move', (data, player, room) => {
|
|
80
|
+
const state = room.getState();
|
|
81
|
+
if (!state.players || state.phase !== 'playing') return;
|
|
82
|
+
|
|
83
|
+
const playerState = state.players[player.id];
|
|
84
|
+
if (!playerState) return;
|
|
85
|
+
|
|
86
|
+
// Apply movement with server-side validation
|
|
87
|
+
state.players[player.id] = applyInput(playerState, data);
|
|
88
|
+
room.setState({ players: state.players });
|
|
89
|
+
|
|
90
|
+
// Broadcast updated position to other players
|
|
91
|
+
room.broadcastExcept(player.id, 'player_position', {
|
|
92
|
+
player_id: player.id,
|
|
93
|
+
x: state.players[player.id].x,
|
|
94
|
+
y: state.players[player.id].y,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ─── Turn-based Actions (validated, stored) ──────────────────
|
|
99
|
+
|
|
100
|
+
server.onAction('attack', (data, player, room) => {
|
|
101
|
+
const state = room.getState();
|
|
102
|
+
if (!state.players || state.phase !== 'playing') return;
|
|
103
|
+
|
|
104
|
+
// Example: handle an attack action
|
|
105
|
+
console.log(`[${room.id}] ${player.name} attacks!`);
|
|
106
|
+
|
|
107
|
+
// Broadcast to all players
|
|
108
|
+
room.broadcast('state_snapshot', {
|
|
109
|
+
room_id: room.id,
|
|
110
|
+
game_state: state,
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── Forfeit ─────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
server.onForfeit((player, room) => {
|
|
117
|
+
console.log(`[${room.id}] ${player.name} forfeited`);
|
|
118
|
+
|
|
119
|
+
const winnerIds = room.playerIds.filter(id => id !== player.id);
|
|
120
|
+
room.setState({ phase: 'finished', winner: winnerIds[0] || null });
|
|
121
|
+
|
|
122
|
+
room.broadcast('match_end', {
|
|
123
|
+
room_id: room.id,
|
|
124
|
+
winner_ids: winnerIds,
|
|
125
|
+
reason: 'forfeit',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
server.submitResult(room.id, {
|
|
129
|
+
winnerIds,
|
|
130
|
+
reason: 'forfeit',
|
|
131
|
+
}).catch(err => console.error('Result submission failed:', err.message));
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// ─── Rematch ─────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
server.onRematch((player, room) => {
|
|
137
|
+
console.log(`[${room.id}] ${player.name} requests rematch`);
|
|
138
|
+
// The SDK already broadcasts rematch_request to other players.
|
|
139
|
+
// You can add custom logic here (e.g., reset game state when both agree).
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ─── Start Server ────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
server.listen(() => {
|
|
145
|
+
console.log(`Game server ready on port ${PORT}`);
|
|
146
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# {{PROJECT_NAME}}
|
|
2
|
+
|
|
3
|
+
A turn-based multiplayer game built on the Usion platform.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run dev
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Open the game inside the Usion app (mobile or web) to test with other players.
|
|
13
|
+
|
|
14
|
+
## Architecture
|
|
15
|
+
|
|
16
|
+
- **Connection**: Platform mode — game relays through Usion's Socket.IO backend
|
|
17
|
+
- **Actions**: `Usion.game.action('move', { cell })` sends validated, sequenced moves
|
|
18
|
+
- **State**: Listen to `onAction` and `onStateUpdate` for opponent moves
|
|
19
|
+
|
|
20
|
+
## Key Files
|
|
21
|
+
|
|
22
|
+
- `app/page.tsx` — Game UI and logic
|
|
23
|
+
- `lib/types.ts` — Game state types
|
|
24
|
+
- `lib/constants.ts` — Board size, win conditions, etc.
|
|
25
|
+
|
|
26
|
+
## How It Works
|
|
27
|
+
|
|
28
|
+
1. SDK initializes with player info from the parent Usion app
|
|
29
|
+
2. Connects to Usion's Socket.IO server
|
|
30
|
+
3. Joins the game room
|
|
31
|
+
4. Players take turns sending actions
|
|
32
|
+
5. Each client validates moves locally and updates the board
|
|
33
|
+
|
|
34
|
+
## Customization
|
|
35
|
+
|
|
36
|
+
Replace the tic-tac-toe logic with your own game:
|
|
37
|
+
|
|
38
|
+
1. Change `BOARD_SIZE` and `WIN_LINES` in `lib/constants.ts`
|
|
39
|
+
2. Update the board rendering in `page.tsx`
|
|
40
|
+
3. Modify the `_cellClick` handler for your game's input
|
|
41
|
+
4. Update `checkWinner` with your win conditions
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const metadata = {
|
|
2
|
+
title: '{{PROJECT_NAME}}',
|
|
3
|
+
description: 'Usion multiplayer game',
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
7
|
+
return (
|
|
8
|
+
<html lang="en">
|
|
9
|
+
<body>{children}</body>
|
|
10
|
+
</html>
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* {{PROJECT_NAME}} — Turn-Based Game (Platform Mode)
|
|
5
|
+
*
|
|
6
|
+
* This is a starter template for a turn-based multiplayer game.
|
|
7
|
+
* It connects through the Usion platform via Socket.IO.
|
|
8
|
+
*
|
|
9
|
+
* Flow: Usion.init → game.connect → game.join → game.action → game.onStateUpdate
|
|
10
|
+
*
|
|
11
|
+
* Replace the board rendering and game logic with your own game.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import Script from 'next/script';
|
|
15
|
+
|
|
16
|
+
export default function GamePage() {
|
|
17
|
+
return (
|
|
18
|
+
<>
|
|
19
|
+
{/* Load Usion SDK from the shared design system server */}
|
|
20
|
+
<Script src="/usion-sdk.js" strategy="beforeInteractive" />
|
|
21
|
+
|
|
22
|
+
<Script id="game-logic" strategy="afterInteractive">{`
|
|
23
|
+
(function() {
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
// ─── Game State ────────────────────────────────────────────
|
|
27
|
+
const BOARD_SIZE = 9;
|
|
28
|
+
const WIN_LINES = [
|
|
29
|
+
[0,1,2],[3,4,5],[6,7,8],
|
|
30
|
+
[0,3,6],[1,4,7],[2,5,8],
|
|
31
|
+
[0,4,8],[2,4,6],
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
const state = {
|
|
35
|
+
phase: 'connecting', // connecting | waiting | playing | finished
|
|
36
|
+
board: Array(BOARD_SIZE).fill(null),
|
|
37
|
+
myId: null,
|
|
38
|
+
roomId: null,
|
|
39
|
+
playerIds: [],
|
|
40
|
+
currentTurn: null,
|
|
41
|
+
winner: null,
|
|
42
|
+
error: null,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ─── DOM Refs ──────────────────────────────────────────────
|
|
46
|
+
const $ = (sel) => document.querySelector(sel);
|
|
47
|
+
|
|
48
|
+
// ─── Rendering ─────────────────────────────────────────────
|
|
49
|
+
function render() {
|
|
50
|
+
// Phase display
|
|
51
|
+
const statusEl = $('#status');
|
|
52
|
+
const boardEl = $('#board');
|
|
53
|
+
const errorEl = $('#error');
|
|
54
|
+
const overlayEl = $('#overlay');
|
|
55
|
+
|
|
56
|
+
if (!statusEl) return; // DOM not ready
|
|
57
|
+
|
|
58
|
+
// Error banner
|
|
59
|
+
if (state.error) {
|
|
60
|
+
errorEl.textContent = state.error;
|
|
61
|
+
errorEl.style.display = 'block';
|
|
62
|
+
setTimeout(() => { errorEl.style.display = 'none'; state.error = null; }, 4000);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Status text
|
|
66
|
+
if (state.phase === 'connecting') {
|
|
67
|
+
statusEl.textContent = 'Connecting...';
|
|
68
|
+
} else if (state.phase === 'waiting') {
|
|
69
|
+
statusEl.textContent = 'Waiting for opponent...';
|
|
70
|
+
} else if (state.phase === 'finished') {
|
|
71
|
+
statusEl.textContent = state.winner === state.myId ? 'You win!' :
|
|
72
|
+
state.winner === 'draw' ? 'Draw!' : 'You lose!';
|
|
73
|
+
} else {
|
|
74
|
+
const isMyTurn = state.currentTurn === state.myId;
|
|
75
|
+
statusEl.textContent = isMyTurn ? 'Your turn' : "Opponent's turn";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Board cells
|
|
79
|
+
const cells = boardEl.querySelectorAll('.cell');
|
|
80
|
+
state.board.forEach((val, i) => {
|
|
81
|
+
const cell = cells[i];
|
|
82
|
+
if (!cell) return;
|
|
83
|
+
cell.textContent = val || '';
|
|
84
|
+
cell.className = 'cell' + (val ? ' taken' : '') +
|
|
85
|
+
(state.phase === 'playing' && !val && state.currentTurn === state.myId ? ' clickable' : '');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Overlay for waiting/finished
|
|
89
|
+
if (state.phase === 'waiting') {
|
|
90
|
+
overlayEl.style.display = 'flex';
|
|
91
|
+
overlayEl.innerHTML = '<div class="overlay-text">Waiting for opponent...</div>';
|
|
92
|
+
} else if (state.phase === 'finished') {
|
|
93
|
+
overlayEl.style.display = 'flex';
|
|
94
|
+
const msg = state.winner === state.myId ? 'You Win!' :
|
|
95
|
+
state.winner === 'draw' ? "It's a Draw!" : 'You Lose!';
|
|
96
|
+
overlayEl.innerHTML =
|
|
97
|
+
'<div class="overlay-text">' + msg + '</div>' +
|
|
98
|
+
'<button class="rematch-btn" onclick="window._requestRematch()">Rematch</button>';
|
|
99
|
+
} else {
|
|
100
|
+
overlayEl.style.display = 'none';
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── Game Logic ────────────────────────────────────────────
|
|
105
|
+
function checkWinner(board) {
|
|
106
|
+
for (const [a, b, c] of WIN_LINES) {
|
|
107
|
+
if (board[a] && board[a] === board[b] && board[b] === board[c]) {
|
|
108
|
+
return board[a]; // 'X' or 'O'
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (board.every(cell => cell !== null)) return 'draw';
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getSymbol(playerId) {
|
|
116
|
+
const idx = state.playerIds.indexOf(playerId);
|
|
117
|
+
return idx === 0 ? 'X' : 'O';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Cell Click Handler ────────────────────────────────────
|
|
121
|
+
window._cellClick = function(index) {
|
|
122
|
+
if (state.phase !== 'playing') return;
|
|
123
|
+
if (state.currentTurn !== state.myId) return;
|
|
124
|
+
if (state.board[index] !== null) return;
|
|
125
|
+
|
|
126
|
+
Usion.game.action('move', { cell: index }).catch(function(err) {
|
|
127
|
+
state.error = 'Failed to send move: ' + err.message;
|
|
128
|
+
render();
|
|
129
|
+
});
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
window._requestRematch = function() {
|
|
133
|
+
Usion.game.requestRematch();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
// ─── SDK Init & Connection ─────────────────────────────────
|
|
137
|
+
Usion.init(function(config) {
|
|
138
|
+
state.myId = config.userId;
|
|
139
|
+
state.roomId = config.roomId;
|
|
140
|
+
|
|
141
|
+
Usion.game.connect()
|
|
142
|
+
.then(function() { return Usion.game.join(config.roomId); })
|
|
143
|
+
.then(function(joinData) {
|
|
144
|
+
state.playerIds = joinData.player_ids || [];
|
|
145
|
+
state.phase = state.playerIds.length >= 2 ? 'playing' : 'waiting';
|
|
146
|
+
|
|
147
|
+
if (joinData.game_state) {
|
|
148
|
+
state.board = joinData.game_state.board || Array(BOARD_SIZE).fill(null);
|
|
149
|
+
state.currentTurn = joinData.game_state.currentTurn || state.playerIds[0];
|
|
150
|
+
} else {
|
|
151
|
+
state.currentTurn = state.playerIds[0];
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
render();
|
|
155
|
+
})
|
|
156
|
+
.catch(function(err) {
|
|
157
|
+
state.error = 'Connection failed: ' + err.message;
|
|
158
|
+
render();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ─── Event Handlers ────────────────────────────────────
|
|
162
|
+
Usion.game.onPlayerJoined(function(data) {
|
|
163
|
+
state.playerIds = data.player_ids || state.playerIds;
|
|
164
|
+
if (state.playerIds.length >= 2 && state.phase === 'waiting') {
|
|
165
|
+
state.phase = 'playing';
|
|
166
|
+
if (!state.currentTurn) state.currentTurn = state.playerIds[0];
|
|
167
|
+
}
|
|
168
|
+
render();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
Usion.game.onStateUpdate(function(data) {
|
|
172
|
+
if (data.game_state) {
|
|
173
|
+
state.board = data.game_state.board || state.board;
|
|
174
|
+
state.currentTurn = data.game_state.currentTurn || state.currentTurn;
|
|
175
|
+
}
|
|
176
|
+
render();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
Usion.game.onAction(function(data) {
|
|
180
|
+
if (data.action_type === 'move' && data.action_data) {
|
|
181
|
+
const cell = data.action_data.cell;
|
|
182
|
+
const symbol = getSymbol(data.player_id);
|
|
183
|
+
state.board[cell] = symbol;
|
|
184
|
+
|
|
185
|
+
// Check for winner
|
|
186
|
+
const result = checkWinner(state.board);
|
|
187
|
+
if (result) {
|
|
188
|
+
state.phase = 'finished';
|
|
189
|
+
state.winner = result === 'draw' ? 'draw' :
|
|
190
|
+
state.playerIds.find(function(id) { return getSymbol(id) === result; });
|
|
191
|
+
} else {
|
|
192
|
+
// Switch turn
|
|
193
|
+
const currentIdx = state.playerIds.indexOf(data.player_id);
|
|
194
|
+
state.currentTurn = state.playerIds[(currentIdx + 1) % state.playerIds.length];
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
render();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
Usion.game.onGameFinished(function(data) {
|
|
201
|
+
state.phase = 'finished';
|
|
202
|
+
state.winner = data.winner_ids && data.winner_ids[0] ? data.winner_ids[0] : 'draw';
|
|
203
|
+
render();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
Usion.game.onGameRestarted(function() {
|
|
207
|
+
state.board = Array(BOARD_SIZE).fill(null);
|
|
208
|
+
state.winner = null;
|
|
209
|
+
state.currentTurn = state.playerIds[0];
|
|
210
|
+
state.phase = 'playing';
|
|
211
|
+
render();
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
Usion.game.onError(function(data) {
|
|
215
|
+
state.error = data.message || 'An error occurred';
|
|
216
|
+
render();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
Usion.game.onDisconnect(function() {
|
|
220
|
+
state.error = 'Disconnected from server';
|
|
221
|
+
render();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
Usion.game.onReconnect(function() {
|
|
225
|
+
state.error = null;
|
|
226
|
+
Usion.game.requestSync();
|
|
227
|
+
render();
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ─── Build DOM ─────────────────────────────────────────────
|
|
232
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
233
|
+
const app = document.getElementById('app');
|
|
234
|
+
if (!app) return;
|
|
235
|
+
|
|
236
|
+
app.innerHTML =
|
|
237
|
+
'<div id="error" class="error-banner" style="display:none"></div>' +
|
|
238
|
+
'<div id="status" class="status">Connecting...</div>' +
|
|
239
|
+
'<div id="board" class="board">' +
|
|
240
|
+
Array(BOARD_SIZE).fill(0).map(function(_, i) {
|
|
241
|
+
return '<div class="cell" onclick="window._cellClick(' + i + ')"></div>';
|
|
242
|
+
}).join('') +
|
|
243
|
+
'</div>' +
|
|
244
|
+
'<div id="overlay" class="overlay" style="display:none"></div>';
|
|
245
|
+
|
|
246
|
+
render();
|
|
247
|
+
});
|
|
248
|
+
})();
|
|
249
|
+
`}</Script>
|
|
250
|
+
|
|
251
|
+
<div id="app" />
|
|
252
|
+
|
|
253
|
+
<style jsx global>{`
|
|
254
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
255
|
+
html, body { height: 100%; background: #000; color: #fff; font-family: -apple-system, system-ui, sans-serif; }
|
|
256
|
+
|
|
257
|
+
#app {
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
align-items: center;
|
|
261
|
+
justify-content: center;
|
|
262
|
+
min-height: 100vh;
|
|
263
|
+
padding: 20px;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.error-banner {
|
|
267
|
+
position: fixed; top: 12px; left: 50%; transform: translateX(-50%);
|
|
268
|
+
background: #ff4444; color: #fff; padding: 8px 16px; border-radius: 8px;
|
|
269
|
+
font-size: 13px; z-index: 100; max-width: 90%;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
.status {
|
|
273
|
+
font-size: 18px; font-weight: 600; margin-bottom: 24px;
|
|
274
|
+
color: #aaa; letter-spacing: 0.5px;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.board {
|
|
278
|
+
display: grid;
|
|
279
|
+
grid-template-columns: repeat(3, 1fr);
|
|
280
|
+
gap: 4px;
|
|
281
|
+
width: min(80vw, 300px);
|
|
282
|
+
aspect-ratio: 1;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.cell {
|
|
286
|
+
background: #111;
|
|
287
|
+
border-radius: 8px;
|
|
288
|
+
display: flex;
|
|
289
|
+
align-items: center;
|
|
290
|
+
justify-content: center;
|
|
291
|
+
font-size: 36px;
|
|
292
|
+
font-weight: 700;
|
|
293
|
+
color: #fff;
|
|
294
|
+
transition: background 0.15s;
|
|
295
|
+
cursor: default;
|
|
296
|
+
user-select: none;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.cell.clickable { cursor: pointer; background: #1a1a1a; }
|
|
300
|
+
.cell.clickable:hover { background: #222; }
|
|
301
|
+
.cell.taken { color: #fff; }
|
|
302
|
+
|
|
303
|
+
.overlay {
|
|
304
|
+
position: fixed; inset: 0;
|
|
305
|
+
background: rgba(0,0,0,0.85);
|
|
306
|
+
display: flex; flex-direction: column;
|
|
307
|
+
align-items: center; justify-content: center;
|
|
308
|
+
z-index: 50;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.overlay-text { font-size: 28px; font-weight: 700; margin-bottom: 20px; }
|
|
312
|
+
|
|
313
|
+
.rematch-btn {
|
|
314
|
+
background: #fff; color: #000; border: none;
|
|
315
|
+
padding: 12px 32px; border-radius: 8px;
|
|
316
|
+
font-size: 16px; font-weight: 600; cursor: pointer;
|
|
317
|
+
}
|
|
318
|
+
.rematch-btn:hover { background: #ddd; }
|
|
319
|
+
`}</style>
|
|
320
|
+
</>
|
|
321
|
+
);
|
|
322
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Game constants — tune these for your game */
|
|
2
|
+
|
|
3
|
+
export const BOARD_SIZE = 9;
|
|
4
|
+
|
|
5
|
+
export const WIN_LINES = [
|
|
6
|
+
[0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
|
|
7
|
+
[0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
|
|
8
|
+
[0, 4, 8], [2, 4, 6], // diagonals
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const SYMBOLS = ['X', 'O', '△', '□'];
|
|
12
|
+
|
|
13
|
+
export const MAX_PLAYERS = {{MAX_PLAYERS}};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Game-specific types */
|
|
2
|
+
|
|
3
|
+
export interface GameState {
|
|
4
|
+
board: (string | null)[];
|
|
5
|
+
currentTurn: string | null;
|
|
6
|
+
winner: string | null;
|
|
7
|
+
isDraw: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface PlayerInfo {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
symbol: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type GamePhase = 'connecting' | 'waiting' | 'playing' | 'finished';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PROJECT_NAME}}",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"scripts": {
|
|
6
|
+
"dev": "next dev -p {{PORT}} -H 0.0.0.0",
|
|
7
|
+
"build": "next build",
|
|
8
|
+
"start": "next start -p {{PORT}}",
|
|
9
|
+
"lint": "next lint"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"next": "^14.2.0",
|
|
13
|
+
"react": "^18.2.0",
|
|
14
|
+
"react-dom": "^18.2.0"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@types/node": "^20.0.0",
|
|
18
|
+
"@types/react": "^18.2.0",
|
|
19
|
+
"typescript": "^5.3.0"
|
|
20
|
+
}
|
|
21
|
+
}
|