create-airjam 0.1.0 → 0.1.2
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/dist/index.js +11 -3
- package/package.json +6 -3
- package/templates/pong/.env.example +11 -0
- package/templates/pong/.env.local +10 -0
- package/templates/pong/AI_INSTRUCTIONS.md +44 -0
- package/templates/pong/README.md +111 -0
- package/templates/pong/airjam-docs/getting-started/architecture/page.md +165 -0
- package/templates/pong/airjam-docs/getting-started/game-ideas/page.md +114 -0
- package/templates/pong/airjam-docs/getting-started/introduction/page.md +122 -0
- package/templates/pong/airjam-docs/how-it-works/host-system/page.md +241 -0
- package/templates/pong/airjam-docs/sdk/hooks/page.md +403 -0
- package/templates/pong/airjam-docs/sdk/input-system/page.md +336 -0
- package/templates/pong/airjam-docs/sdk/networked-state/page.md +575 -0
- package/templates/pong/dist/assets/index-B9l0NKly.js +269 -0
- package/templates/pong/dist/assets/index-CHKqdIQG.css +1 -0
- package/templates/pong/dist/index.html +14 -0
- package/templates/pong/eslint.config.js +33 -0
- package/templates/pong/index.html +6 -1
- package/templates/pong/node_modules/.bin/air-jam-server +17 -0
- package/templates/pong/node_modules/.bin/eslint +17 -0
- package/templates/pong/node_modules/.bin/eslint-config-prettier +17 -0
- package/templates/pong/node_modules/.bin/jiti +17 -0
- package/templates/pong/node_modules/.bin/tsc +17 -0
- package/templates/pong/node_modules/.bin/tsserver +17 -0
- package/templates/pong/node_modules/.bin/tsx +17 -0
- package/templates/pong/node_modules/.bin/vite +17 -0
- package/templates/pong/node_modules/.vite/deps/@air-jam_sdk.js +66143 -0
- package/templates/pong/node_modules/.vite/deps/@air-jam_sdk.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/_metadata.json +73 -0
- package/templates/pong/node_modules/.vite/deps/chunk-3TUQC5ZT.js +292 -0
- package/templates/pong/node_modules/.vite/deps/chunk-3TUQC5ZT.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/chunk-DC5AMYBS.js +38 -0
- package/templates/pong/node_modules/.vite/deps/chunk-DC5AMYBS.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/chunk-QUPSG5AV.js +280 -0
- package/templates/pong/node_modules/.vite/deps/chunk-QUPSG5AV.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/chunk-TYOCAO5S.js +13810 -0
- package/templates/pong/node_modules/.vite/deps/chunk-TYOCAO5S.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/chunk-YG4BJP3V.js +1004 -0
- package/templates/pong/node_modules/.vite/deps/chunk-YG4BJP3V.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/package.json +3 -0
- package/templates/pong/node_modules/.vite/deps/react-dom.js +6 -0
- package/templates/pong/node_modules/.vite/deps/react-dom.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/react-dom_client.js +20217 -0
- package/templates/pong/node_modules/.vite/deps/react-dom_client.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/react-router-dom.js +13900 -0
- package/templates/pong/node_modules/.vite/deps/react-router-dom.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/react.js +5 -0
- package/templates/pong/node_modules/.vite/deps/react.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/react_jsx-dev-runtime.js +278 -0
- package/templates/pong/node_modules/.vite/deps/react_jsx-dev-runtime.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/react_jsx-runtime.js +6 -0
- package/templates/pong/node_modules/.vite/deps/react_jsx-runtime.js.map +7 -0
- package/templates/pong/node_modules/.vite/deps/zod.js +476 -0
- package/templates/pong/node_modules/.vite/deps/zod.js.map +7 -0
- package/templates/pong/package.json +12 -1
- package/templates/pong/src/App.tsx +2 -2
- package/templates/pong/src/controller-view.tsx +143 -0
- package/templates/pong/src/host-view.tsx +401 -0
- package/templates/pong/src/main.tsx +2 -1
- package/templates/pong/src/store.ts +80 -0
- package/templates/pong/tsconfig.json +3 -2
- package/templates/pong/vite.config.ts +3 -0
- package/templates/pong/src/ControllerView.tsx +0 -64
- package/templates/pong/src/HostView.tsx +0 -148
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AirJamDebug,
|
|
3
|
+
AirJamOverlay,
|
|
4
|
+
PlayerAvatar,
|
|
5
|
+
useAirJamHost,
|
|
6
|
+
} from "@air-jam/sdk";
|
|
7
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
8
|
+
import { usePongStore } from "./store";
|
|
9
|
+
import { gameInputSchema } from "./types";
|
|
10
|
+
|
|
11
|
+
const FIELD_WIDTH = 1000;
|
|
12
|
+
const FIELD_HEIGHT = 600;
|
|
13
|
+
const PADDLE_HEIGHT = 100;
|
|
14
|
+
const PADDLE_WIDTH = 15;
|
|
15
|
+
const PADDLE_OFFSET = 30;
|
|
16
|
+
const BALL_SIZE = 15;
|
|
17
|
+
const PADDLE_SPEED = 6;
|
|
18
|
+
const BALL_SPEED = 3;
|
|
19
|
+
const TEAM1_COLOR = "#f97316"; // (Solaris)
|
|
20
|
+
const TEAM2_COLOR = "#38bdf8"; // (Nebulon)
|
|
21
|
+
|
|
22
|
+
export function HostView() {
|
|
23
|
+
const host = useAirJamHost<typeof gameInputSchema>();
|
|
24
|
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
|
25
|
+
const [countdown, setCountdown] = useState<number | null>(null);
|
|
26
|
+
|
|
27
|
+
// 1. Read Game Logic State (UI & Rules)
|
|
28
|
+
// Shared networked state with zustand reducers to minimize re-renders
|
|
29
|
+
const scores = usePongStore((state) => state.scores);
|
|
30
|
+
const teamAssignments = usePongStore((state) => state.teamAssignments);
|
|
31
|
+
const actions = usePongStore((state) => state.actions);
|
|
32
|
+
|
|
33
|
+
// Game state refs (to avoid re-renders in game loop)
|
|
34
|
+
const gameState = useRef({
|
|
35
|
+
paddle1FrontY: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,
|
|
36
|
+
paddle1BackY: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,
|
|
37
|
+
paddle2FrontY: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,
|
|
38
|
+
paddle2BackY: FIELD_HEIGHT / 2 - PADDLE_HEIGHT / 2,
|
|
39
|
+
ballX: FIELD_WIDTH / 2,
|
|
40
|
+
ballY: FIELD_HEIGHT / 2,
|
|
41
|
+
ballVX: BALL_SPEED,
|
|
42
|
+
ballVY: BALL_SPEED,
|
|
43
|
+
lastTouchedTeam: null as "team1" | "team2" | null, // Track which team last touched the ball
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const resetBall = useCallback(() => {
|
|
47
|
+
const state = gameState.current;
|
|
48
|
+
state.ballX = FIELD_WIDTH / 2;
|
|
49
|
+
state.ballY = FIELD_HEIGHT / 2;
|
|
50
|
+
state.ballVX = BALL_SPEED * (Math.random() > 0.5 ? 1 : -1);
|
|
51
|
+
state.ballVY = BALL_SPEED * (Math.random() > 0.5 ? 1 : -1);
|
|
52
|
+
state.lastTouchedTeam = null; // Reset ball color to neutral
|
|
53
|
+
}, []);
|
|
54
|
+
|
|
55
|
+
// Handle countdown timer (only when playing)
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (countdown === null) return;
|
|
58
|
+
if (host.gameState !== "playing") return; // Don't progress countdown when paused
|
|
59
|
+
|
|
60
|
+
if (countdown === 0) {
|
|
61
|
+
resetBall();
|
|
62
|
+
// Defer state update to avoid cascading renders
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
setCountdown(null);
|
|
65
|
+
}, 0);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const timer = setTimeout(() => {
|
|
70
|
+
setCountdown(countdown - 1);
|
|
71
|
+
}, 1000);
|
|
72
|
+
|
|
73
|
+
return () => clearTimeout(timer);
|
|
74
|
+
}, [countdown, host.gameState, resetBall]);
|
|
75
|
+
|
|
76
|
+
// Game loop
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const canvas = canvasRef.current;
|
|
79
|
+
if (!canvas) return;
|
|
80
|
+
const ctx = canvas.getContext("2d");
|
|
81
|
+
if (!ctx) return;
|
|
82
|
+
|
|
83
|
+
// Handle high DPI displays
|
|
84
|
+
const dpr = window.devicePixelRatio || 1;
|
|
85
|
+
canvas.width = FIELD_WIDTH * dpr;
|
|
86
|
+
canvas.height = FIELD_HEIGHT * dpr;
|
|
87
|
+
canvas.style.width = `${FIELD_WIDTH}px`;
|
|
88
|
+
canvas.style.height = `${FIELD_HEIGHT}px`;
|
|
89
|
+
ctx.scale(dpr, dpr);
|
|
90
|
+
|
|
91
|
+
let animationId: number;
|
|
92
|
+
|
|
93
|
+
const gameLoop = () => {
|
|
94
|
+
const state = gameState.current;
|
|
95
|
+
const players = host.players;
|
|
96
|
+
const isPlaying = host.gameState === "playing";
|
|
97
|
+
|
|
98
|
+
// Only process game logic when playing (not paused)
|
|
99
|
+
if (isPlaying) {
|
|
100
|
+
// Loop through players and apply input based on team and position
|
|
101
|
+
players.forEach((p) => {
|
|
102
|
+
// Get Raw Input (High Frequency)
|
|
103
|
+
const input = host.getInput(p.id);
|
|
104
|
+
|
|
105
|
+
// Get Logic State (Low Frequency)
|
|
106
|
+
const assignment = teamAssignments[p.id];
|
|
107
|
+
|
|
108
|
+
if (input && assignment) {
|
|
109
|
+
const { team, position } = assignment;
|
|
110
|
+
// Apply physics based on team and position!
|
|
111
|
+
if (team === "team1") {
|
|
112
|
+
if (position === "front") {
|
|
113
|
+
state.paddle1FrontY += input.direction * PADDLE_SPEED;
|
|
114
|
+
state.paddle1FrontY = Math.max(
|
|
115
|
+
0,
|
|
116
|
+
Math.min(FIELD_HEIGHT - PADDLE_HEIGHT, state.paddle1FrontY),
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
state.paddle1BackY += input.direction * PADDLE_SPEED;
|
|
120
|
+
state.paddle1BackY = Math.max(
|
|
121
|
+
0,
|
|
122
|
+
Math.min(FIELD_HEIGHT - PADDLE_HEIGHT, state.paddle1BackY),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (team === "team2") {
|
|
127
|
+
if (position === "front") {
|
|
128
|
+
state.paddle2FrontY += input.direction * PADDLE_SPEED;
|
|
129
|
+
state.paddle2FrontY = Math.max(
|
|
130
|
+
0,
|
|
131
|
+
Math.min(FIELD_HEIGHT - PADDLE_HEIGHT, state.paddle2FrontY),
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
state.paddle2BackY += input.direction * PADDLE_SPEED;
|
|
135
|
+
state.paddle2BackY = Math.max(
|
|
136
|
+
0,
|
|
137
|
+
Math.min(FIELD_HEIGHT - PADDLE_HEIGHT, state.paddle2BackY),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Move ball (only if not in countdown)
|
|
145
|
+
if (countdown === null) {
|
|
146
|
+
state.ballX += state.ballVX;
|
|
147
|
+
state.ballY += state.ballVY;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Ball collision with top/bottom walls
|
|
151
|
+
if (state.ballY <= 0 || state.ballY >= FIELD_HEIGHT - BALL_SIZE) {
|
|
152
|
+
state.ballVY *= -1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Ball collision with paddles
|
|
156
|
+
// Team 1 - Left side (Orange)
|
|
157
|
+
const team1Players = players.filter(
|
|
158
|
+
(p) => teamAssignments[p.id]?.team === "team1",
|
|
159
|
+
);
|
|
160
|
+
// Front paddle collision (only if front player exists)
|
|
161
|
+
const team1FrontPlayer = team1Players.find(
|
|
162
|
+
(p) => teamAssignments[p.id]?.position === "front",
|
|
163
|
+
);
|
|
164
|
+
if (team1FrontPlayer) {
|
|
165
|
+
if (
|
|
166
|
+
state.ballX <= PADDLE_OFFSET + PADDLE_WIDTH &&
|
|
167
|
+
state.ballX >= PADDLE_OFFSET &&
|
|
168
|
+
state.ballY + BALL_SIZE >= state.paddle1FrontY &&
|
|
169
|
+
state.ballY <= state.paddle1FrontY + PADDLE_HEIGHT
|
|
170
|
+
) {
|
|
171
|
+
state.ballVX = Math.abs(state.ballVX);
|
|
172
|
+
state.lastTouchedTeam = "team1";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Back paddle collision (only if back player exists)
|
|
176
|
+
const team1BackPlayer = team1Players.find(
|
|
177
|
+
(p) => teamAssignments[p.id]?.position === "back",
|
|
178
|
+
);
|
|
179
|
+
if (team1BackPlayer) {
|
|
180
|
+
const backPaddle1X = PADDLE_OFFSET / 2;
|
|
181
|
+
if (
|
|
182
|
+
state.ballX <= backPaddle1X + PADDLE_WIDTH &&
|
|
183
|
+
state.ballX >= backPaddle1X &&
|
|
184
|
+
state.ballY + BALL_SIZE >= state.paddle1BackY &&
|
|
185
|
+
state.ballY <= state.paddle1BackY + PADDLE_HEIGHT
|
|
186
|
+
) {
|
|
187
|
+
state.ballVX = Math.abs(state.ballVX);
|
|
188
|
+
state.lastTouchedTeam = "team1";
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Team 2 - Right side (Blue)
|
|
192
|
+
const team2Players = players.filter(
|
|
193
|
+
(p) => teamAssignments[p.id]?.team === "team2",
|
|
194
|
+
);
|
|
195
|
+
// Front paddle collision (only if front player exists)
|
|
196
|
+
const team2FrontPlayer = team2Players.find(
|
|
197
|
+
(p) => teamAssignments[p.id]?.position === "front",
|
|
198
|
+
);
|
|
199
|
+
if (team2FrontPlayer) {
|
|
200
|
+
if (
|
|
201
|
+
state.ballX >=
|
|
202
|
+
FIELD_WIDTH - PADDLE_OFFSET - PADDLE_WIDTH - BALL_SIZE &&
|
|
203
|
+
state.ballX <= FIELD_WIDTH - PADDLE_OFFSET &&
|
|
204
|
+
state.ballY + BALL_SIZE >= state.paddle2FrontY &&
|
|
205
|
+
state.ballY <= state.paddle2FrontY + PADDLE_HEIGHT
|
|
206
|
+
) {
|
|
207
|
+
state.ballVX = -Math.abs(state.ballVX);
|
|
208
|
+
state.lastTouchedTeam = "team2";
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Back paddle collision (only if back player exists)
|
|
212
|
+
const team2BackPlayer = team2Players.find(
|
|
213
|
+
(p) => teamAssignments[p.id]?.position === "back",
|
|
214
|
+
);
|
|
215
|
+
if (team2BackPlayer) {
|
|
216
|
+
const backPaddle2X = FIELD_WIDTH - PADDLE_OFFSET / 2 - PADDLE_WIDTH;
|
|
217
|
+
if (
|
|
218
|
+
state.ballX >= backPaddle2X - BALL_SIZE &&
|
|
219
|
+
state.ballX <= backPaddle2X + PADDLE_WIDTH &&
|
|
220
|
+
state.ballY + BALL_SIZE >= state.paddle2BackY &&
|
|
221
|
+
state.ballY <= state.paddle2BackY + PADDLE_HEIGHT
|
|
222
|
+
) {
|
|
223
|
+
state.ballVX = -Math.abs(state.ballVX);
|
|
224
|
+
state.lastTouchedTeam = "team2";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Scoring
|
|
229
|
+
if (countdown === null) {
|
|
230
|
+
if (state.ballX <= 0) {
|
|
231
|
+
actions.scorePoint("team2");
|
|
232
|
+
setCountdown(3);
|
|
233
|
+
}
|
|
234
|
+
if (state.ballX >= FIELD_WIDTH - BALL_SIZE) {
|
|
235
|
+
actions.scorePoint("team1");
|
|
236
|
+
setCountdown(3);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Draw
|
|
242
|
+
ctx.fillStyle = "#000";
|
|
243
|
+
ctx.fillRect(0, 0, FIELD_WIDTH, FIELD_HEIGHT);
|
|
244
|
+
|
|
245
|
+
// Paddles - Team 1 (Left side - Orange)
|
|
246
|
+
// Only draw paddles for players that are actually assigned
|
|
247
|
+
const team1Players = players.filter(
|
|
248
|
+
(p) => teamAssignments[p.id]?.team === "team1",
|
|
249
|
+
);
|
|
250
|
+
if (team1Players.length > 0) {
|
|
251
|
+
ctx.fillStyle = TEAM1_COLOR;
|
|
252
|
+
// Check if front position is assigned
|
|
253
|
+
const frontPlayer = team1Players.find(
|
|
254
|
+
(p) => teamAssignments[p.id]?.position === "front",
|
|
255
|
+
);
|
|
256
|
+
if (frontPlayer) {
|
|
257
|
+
ctx.fillRect(
|
|
258
|
+
PADDLE_OFFSET,
|
|
259
|
+
state.paddle1FrontY,
|
|
260
|
+
PADDLE_WIDTH,
|
|
261
|
+
PADDLE_HEIGHT,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
// Check if back position is assigned
|
|
265
|
+
const backPlayer = team1Players.find(
|
|
266
|
+
(p) => teamAssignments[p.id]?.position === "back",
|
|
267
|
+
);
|
|
268
|
+
if (backPlayer) {
|
|
269
|
+
ctx.fillRect(
|
|
270
|
+
PADDLE_OFFSET / 2,
|
|
271
|
+
state.paddle1BackY,
|
|
272
|
+
PADDLE_WIDTH,
|
|
273
|
+
PADDLE_HEIGHT,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Paddles - Team 2 (Right side - Blue)
|
|
279
|
+
// Only draw paddles for players that are actually assigned
|
|
280
|
+
const team2Players = players.filter(
|
|
281
|
+
(p) => teamAssignments[p.id]?.team === "team2",
|
|
282
|
+
);
|
|
283
|
+
if (team2Players.length > 0) {
|
|
284
|
+
ctx.fillStyle = TEAM2_COLOR;
|
|
285
|
+
// Check if front position is assigned
|
|
286
|
+
const frontPlayer = team2Players.find(
|
|
287
|
+
(p) => teamAssignments[p.id]?.position === "front",
|
|
288
|
+
);
|
|
289
|
+
if (frontPlayer) {
|
|
290
|
+
ctx.fillRect(
|
|
291
|
+
FIELD_WIDTH - PADDLE_OFFSET - PADDLE_WIDTH,
|
|
292
|
+
state.paddle2FrontY,
|
|
293
|
+
PADDLE_WIDTH,
|
|
294
|
+
PADDLE_HEIGHT,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
// Check if back position is assigned
|
|
298
|
+
const backPlayer = team2Players.find(
|
|
299
|
+
(p) => teamAssignments[p.id]?.position === "back",
|
|
300
|
+
);
|
|
301
|
+
if (backPlayer) {
|
|
302
|
+
ctx.fillRect(
|
|
303
|
+
FIELD_WIDTH - PADDLE_OFFSET / 2 - PADDLE_WIDTH,
|
|
304
|
+
state.paddle2BackY,
|
|
305
|
+
PADDLE_WIDTH,
|
|
306
|
+
PADDLE_HEIGHT,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// Ball (color based on last team that touched it)
|
|
311
|
+
ctx.fillStyle =
|
|
312
|
+
state.lastTouchedTeam === "team1"
|
|
313
|
+
? TEAM1_COLOR
|
|
314
|
+
: state.lastTouchedTeam === "team2"
|
|
315
|
+
? TEAM2_COLOR
|
|
316
|
+
: "#fff"; // Neutral white if no team has touched it
|
|
317
|
+
ctx.beginPath();
|
|
318
|
+
ctx.arc(
|
|
319
|
+
state.ballX + BALL_SIZE / 2,
|
|
320
|
+
state.ballY + BALL_SIZE / 2,
|
|
321
|
+
BALL_SIZE / 2,
|
|
322
|
+
0,
|
|
323
|
+
Math.PI * 2,
|
|
324
|
+
);
|
|
325
|
+
ctx.fill();
|
|
326
|
+
// Center line
|
|
327
|
+
ctx.setLineDash([5, 15]);
|
|
328
|
+
ctx.beginPath();
|
|
329
|
+
ctx.moveTo(FIELD_WIDTH / 2, 0);
|
|
330
|
+
ctx.lineTo(FIELD_WIDTH / 2, FIELD_HEIGHT);
|
|
331
|
+
ctx.strokeStyle = "#333";
|
|
332
|
+
ctx.stroke();
|
|
333
|
+
|
|
334
|
+
// Draw countdown
|
|
335
|
+
if (countdown !== null) {
|
|
336
|
+
ctx.fillStyle = "#fff";
|
|
337
|
+
ctx.font = "bold 120px Arial";
|
|
338
|
+
ctx.textAlign = "center";
|
|
339
|
+
ctx.textBaseline = "middle";
|
|
340
|
+
ctx.fillText(countdown.toString(), FIELD_WIDTH / 2, FIELD_HEIGHT / 2);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
animationId = requestAnimationFrame(gameLoop);
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
gameLoop();
|
|
347
|
+
return () => cancelAnimationFrame(animationId);
|
|
348
|
+
}, [host, resetBall, countdown, teamAssignments, actions]);
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<>
|
|
352
|
+
<AirJamOverlay />
|
|
353
|
+
|
|
354
|
+
{/* Debug State Component */}
|
|
355
|
+
<div className="fixed top-20 right-4 z-50">
|
|
356
|
+
<AirJamDebug
|
|
357
|
+
state={usePongStore((state) => state)}
|
|
358
|
+
title="Pong Game State"
|
|
359
|
+
/>
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
<div className="relative flex min-h-screen flex-col items-center justify-center bg-zinc-900 p-4">
|
|
363
|
+
{/* UI Layer using Store Data */}
|
|
364
|
+
<div className="mb-4 flex w-full max-w-4xl items-center gap-4">
|
|
365
|
+
{/* Left flex spacer */}
|
|
366
|
+
<div className="flex-1" />
|
|
367
|
+
|
|
368
|
+
{/* Team 1 Avatars */}
|
|
369
|
+
<div className="flex w-20 items-center justify-end gap-2">
|
|
370
|
+
{host.players
|
|
371
|
+
.filter((p) => teamAssignments[p.id]?.team === "team1")
|
|
372
|
+
.map((player) => (
|
|
373
|
+
<PlayerAvatar key={player.id} player={player} size="sm" />
|
|
374
|
+
))}
|
|
375
|
+
</div>
|
|
376
|
+
|
|
377
|
+
{/* Score */}
|
|
378
|
+
<div className="flex items-center gap-2 text-2xl font-bold">
|
|
379
|
+
<span style={{ color: TEAM1_COLOR }}>{scores.team1}</span>
|
|
380
|
+
<span className="text-white">-</span>
|
|
381
|
+
<span style={{ color: TEAM2_COLOR }}>{scores.team2}</span>
|
|
382
|
+
</div>
|
|
383
|
+
|
|
384
|
+
{/* Team 2 Avatars */}
|
|
385
|
+
<div className="flex w-20 items-center gap-2">
|
|
386
|
+
{host.players
|
|
387
|
+
.filter((p) => teamAssignments[p.id]?.team === "team2")
|
|
388
|
+
.map((player) => (
|
|
389
|
+
<PlayerAvatar key={player.id} player={player} size="sm" />
|
|
390
|
+
))}
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
{/* Right flex spacer */}
|
|
394
|
+
<div className="flex-1" />
|
|
395
|
+
</div>
|
|
396
|
+
|
|
397
|
+
<canvas ref={canvasRef} className="rounded-lg border-2 border-white" />
|
|
398
|
+
</div>
|
|
399
|
+
</>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import "@air-jam/sdk/styles.css";
|
|
1
2
|
import { StrictMode } from "react";
|
|
2
3
|
import { createRoot } from "react-dom/client";
|
|
3
4
|
import { BrowserRouter } from "react-router-dom";
|
|
@@ -9,5 +10,5 @@ createRoot(document.getElementById("root")!).render(
|
|
|
9
10
|
<BrowserRouter>
|
|
10
11
|
<App />
|
|
11
12
|
</BrowserRouter>
|
|
12
|
-
</StrictMode
|
|
13
|
+
</StrictMode>,
|
|
13
14
|
);
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createAirJamStore } from "@air-jam/sdk";
|
|
2
|
+
|
|
3
|
+
export interface TeamAssignment {
|
|
4
|
+
team: "team1" | "team2";
|
|
5
|
+
position: "front" | "back";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PongState {
|
|
9
|
+
scores: { team1: number; team2: number };
|
|
10
|
+
// Map controllerId -> { team, position }
|
|
11
|
+
teamAssignments: Record<string, TeamAssignment>;
|
|
12
|
+
|
|
13
|
+
actions: {
|
|
14
|
+
joinTeam: (team: "team1" | "team2", playerId?: string) => void;
|
|
15
|
+
resetGame: () => void;
|
|
16
|
+
scorePoint: (team: "team1" | "team2") => void;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// This store is automatically synced between the host and all controllers.
|
|
21
|
+
export const usePongStore = createAirJamStore<PongState>((set) => ({
|
|
22
|
+
scores: { team1: 0, team2: 0 },
|
|
23
|
+
teamAssignments: {},
|
|
24
|
+
|
|
25
|
+
actions: {
|
|
26
|
+
// Note: playerId is injected by the SDK on the Host side automatically
|
|
27
|
+
joinTeam: (team, playerId) => {
|
|
28
|
+
if (!playerId) return;
|
|
29
|
+
set((state) => {
|
|
30
|
+
const newAssignments = { ...state.teamAssignments };
|
|
31
|
+
const currentAssignment = newAssignments[playerId];
|
|
32
|
+
|
|
33
|
+
// If player is already on this team, don't change anything
|
|
34
|
+
if (currentAssignment && currentAssignment.team === team) {
|
|
35
|
+
return state;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Remove player from their current team if they're switching
|
|
39
|
+
if (currentAssignment && currentAssignment.team !== team) {
|
|
40
|
+
delete newAssignments[playerId];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Count players in the target team (excluding the current player)
|
|
44
|
+
const teamPlayers = Object.values(newAssignments).filter(
|
|
45
|
+
(assignment) => assignment.team === team,
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Enforce max 2 players per team
|
|
49
|
+
if (teamPlayers.length >= 2) {
|
|
50
|
+
// Team is full, don't allow assignment
|
|
51
|
+
return state;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Assign position: first player = front, second = back
|
|
55
|
+
const position: "front" | "back" =
|
|
56
|
+
teamPlayers.length === 0 ? "front" : "back";
|
|
57
|
+
|
|
58
|
+
newAssignments[playerId] = { team, position };
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
teamAssignments: newAssignments,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
resetGame: () =>
|
|
67
|
+
set({
|
|
68
|
+
scores: { team1: 0, team2: 0 },
|
|
69
|
+
teamAssignments: {},
|
|
70
|
+
}),
|
|
71
|
+
|
|
72
|
+
scorePoint: (team) =>
|
|
73
|
+
set((state) => ({
|
|
74
|
+
scores: {
|
|
75
|
+
...state.scores,
|
|
76
|
+
[team]: state.scores[team] + 1,
|
|
77
|
+
},
|
|
78
|
+
})),
|
|
79
|
+
},
|
|
80
|
+
}));
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import tailwindcss from "@tailwindcss/vite";
|
|
2
2
|
import react from "@vitejs/plugin-react";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
4
5
|
import { defineConfig } from "vite";
|
|
5
6
|
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
|
|
6
9
|
export default defineConfig({
|
|
7
10
|
plugins: [react(), tailwindcss()],
|
|
8
11
|
resolve: {
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { ControllerShell, useAirJamController } from "@air-jam/sdk";
|
|
2
|
-
import { useRef, useEffect } from "react";
|
|
3
|
-
|
|
4
|
-
export function ControllerView() {
|
|
5
|
-
const controller = useAirJamController();
|
|
6
|
-
const directionRef = useRef(0);
|
|
7
|
-
|
|
8
|
-
// Send input loop
|
|
9
|
-
useEffect(() => {
|
|
10
|
-
if (controller.connectionStatus !== "connected") return;
|
|
11
|
-
|
|
12
|
-
let animationId: number;
|
|
13
|
-
const loop = () => {
|
|
14
|
-
controller.sendInput({
|
|
15
|
-
direction: directionRef.current,
|
|
16
|
-
action: false,
|
|
17
|
-
});
|
|
18
|
-
animationId = requestAnimationFrame(loop);
|
|
19
|
-
};
|
|
20
|
-
loop();
|
|
21
|
-
return () => cancelAnimationFrame(animationId);
|
|
22
|
-
}, [controller.connectionStatus, controller]);
|
|
23
|
-
|
|
24
|
-
return (
|
|
25
|
-
<ControllerShell
|
|
26
|
-
connectionStatus={controller.connectionStatus}
|
|
27
|
-
roomId={controller.roomId}
|
|
28
|
-
>
|
|
29
|
-
<div className="flex h-full w-full flex-col items-center justify-center gap-8 bg-gray-900 p-4">
|
|
30
|
-
<h2 className="text-2xl font-bold text-white">Your Controller</h2>
|
|
31
|
-
|
|
32
|
-
<div className="flex flex-col gap-4">
|
|
33
|
-
{/* Up button */}
|
|
34
|
-
<button
|
|
35
|
-
type="button"
|
|
36
|
-
className="rounded-lg bg-blue-600 px-12 py-8 text-2xl font-bold text-white active:bg-blue-700"
|
|
37
|
-
onTouchStart={() => (directionRef.current = -1)}
|
|
38
|
-
onTouchEnd={() => (directionRef.current = 0)}
|
|
39
|
-
onMouseDown={() => (directionRef.current = -1)}
|
|
40
|
-
onMouseUp={() => (directionRef.current = 0)}
|
|
41
|
-
>
|
|
42
|
-
▲ UP
|
|
43
|
-
</button>
|
|
44
|
-
|
|
45
|
-
{/* Down button */}
|
|
46
|
-
<button
|
|
47
|
-
type="button"
|
|
48
|
-
className="rounded-lg bg-blue-600 px-12 py-8 text-2xl font-bold text-white active:bg-blue-700"
|
|
49
|
-
onTouchStart={() => (directionRef.current = 1)}
|
|
50
|
-
onTouchEnd={() => (directionRef.current = 0)}
|
|
51
|
-
onMouseDown={() => (directionRef.current = 1)}
|
|
52
|
-
onMouseUp={() => (directionRef.current = 0)}
|
|
53
|
-
>
|
|
54
|
-
▼ DOWN
|
|
55
|
-
</button>
|
|
56
|
-
</div>
|
|
57
|
-
|
|
58
|
-
<p className="text-sm text-gray-400">
|
|
59
|
-
Hold to move your paddle
|
|
60
|
-
</p>
|
|
61
|
-
</div>
|
|
62
|
-
</ControllerShell>
|
|
63
|
-
);
|
|
64
|
-
}
|