init
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,17 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY docker/40-runtime-config.sh /docker-entrypoint.d/40-runtime-config.sh
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
RUN chmod +x /docker-entrypoint.d/40-runtime-config.sh
|
||||
|
||||
# Full URL of the game hub as seen by the browser, e.g. https://blocks.example.com/hub.
|
||||
# Empty = same origin + /hub (reverse proxy routes /hub to the server).
|
||||
ENV SERVER_URL=""
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# Writes the runtime configuration consumed by the website (see src/config/runtimeConfig.ts).
|
||||
set -eu
|
||||
|
||||
# Escape backslashes and double quotes for the JS string literal.
|
||||
server_url=$(printf '%s' "${SERVER_URL:-}" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
|
||||
cat > /usr/share/nginx/html/config.js <<CONFIG
|
||||
window.__ACTIONBLOCKS_CONFIG__ = { serverUrl: "${server_url}" };
|
||||
CONFIG
|
||||
@@ -0,0 +1,35 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
# Fingerprinted build output can be cached forever.
|
||||
location /static/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
}
|
||||
|
||||
# Exchangeable graphics (public/assets) are not fingerprinted; revalidate them
|
||||
# so replaced files show up immediately.
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
location = /config.js {
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache";
|
||||
}
|
||||
|
||||
# Single page app fallback.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet" />
|
||||
<title>Action Blocks</title>
|
||||
<!-- Runtime configuration, rewritten by the container on startup. -->
|
||||
<script src="/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "actionblocks-website",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"typecheck": "tsc",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@microsoft/signalr": "10.0.11",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"@types/react": "19.3.0",
|
||||
"@types/react-dom": "19.3.0",
|
||||
"@vitejs/plugin-react": "5.2.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "7.3.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 4" width="64" height="64" shape-rendering="crispEdges"><rect width="4" height="4" fill="#000"/><rect x="0" y="0" width="2" height="2" fill="#ff4256"/><rect x="2" y="0" width="2" height="2" fill="#ffe23f"/><rect x="0" y="2" width="2" height="2" fill="#3a66ff"/><rect x="2" y="2" width="2" height="2" fill="#4fe35f"/></svg>
|
||||
|
After Width: | Height: | Size: 377 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="3" y="0" width="1" height="1" fill="#ffffff"/><rect x="4" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="1" width="1" height="1" fill="#ffffff"/><rect x="4" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="1" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="6" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="4" y="4" width="1" height="1" fill="#ffffff"/><rect x="0" y="6" width="1" height="1" fill="#ff4256"/><rect x="1" y="6" width="1" height="1" fill="#ff4256"/><rect x="2" y="6" width="1" height="1" fill="#ff4256"/><rect x="3" y="6" width="1" height="1" fill="#ff4256"/><rect x="5" y="6" width="1" height="1" fill="#ff4256"/><rect x="6" y="6" width="1" height="1" fill="#ff4256"/><rect x="7" y="6" width="1" height="1" fill="#ff4256"/><rect x="0" y="7" width="1" height="1" fill="#ff4256"/><rect x="1" y="7" width="1" height="1" fill="#ff4256"/><rect x="2" y="7" width="1" height="1" fill="#ff4256"/><rect x="3" y="7" width="1" height="1" fill="#ff4256"/><rect x="5" y="7" width="1" height="1" fill="#ff4256"/><rect x="6" y="7" width="1" height="1" fill="#ff4256"/><rect x="7" y="7" width="1" height="1" fill="#ff4256"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="0" y="2" width="1" height="1" fill="#c452ff"/><rect x="7" y="2" width="1" height="1" fill="#c452ff"/><rect x="1" y="3" width="1" height="1" fill="#c452ff"/><rect x="2" y="3" width="1" height="1" fill="#c452ff"/><rect x="3" y="3" width="1" height="1" fill="#c452ff"/><rect x="4" y="3" width="1" height="1" fill="#c452ff"/><rect x="5" y="3" width="1" height="1" fill="#c452ff"/><rect x="6" y="3" width="1" height="1" fill="#c452ff"/><rect x="0" y="4" width="1" height="1" fill="#c452ff"/><rect x="2" y="4" width="1" height="1" fill="#c452ff"/><rect x="5" y="4" width="1" height="1" fill="#c452ff"/><rect x="7" y="4" width="1" height="1" fill="#c452ff"/><rect x="2" y="5" width="1" height="1" fill="#c452ff"/><rect x="5" y="5" width="1" height="1" fill="#c452ff"/></svg>
|
||||
|
After Width: | Height: | Size: 930 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="1" y="0" width="1" height="1" fill="#ffffff"/><rect x="6" y="0" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="1" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffffff"/><rect x="1" y="5" width="1" height="1" fill="#ffffff"/><rect x="6" y="5" width="1" height="1" fill="#ffffff"/><rect x="0" y="6" width="1" height="1" fill="#4fe35f"/><rect x="1" y="6" width="1" height="1" fill="#4fe35f"/><rect x="2" y="6" width="1" height="1" fill="#4fe35f"/><rect x="3" y="6" width="1" height="1" fill="#4fe35f"/><rect x="4" y="6" width="1" height="1" fill="#4fe35f"/><rect x="5" y="6" width="1" height="1" fill="#4fe35f"/><rect x="6" y="6" width="1" height="1" fill="#4fe35f"/><rect x="7" y="6" width="1" height="1" fill="#4fe35f"/><rect x="0" y="7" width="1" height="1" fill="#4fe35f"/><rect x="1" y="7" width="1" height="1" fill="#4fe35f"/><rect x="2" y="7" width="1" height="1" fill="#4fe35f"/><rect x="3" y="7" width="1" height="1" fill="#4fe35f"/><rect x="4" y="7" width="1" height="1" fill="#4fe35f"/><rect x="5" y="7" width="1" height="1" fill="#4fe35f"/><rect x="6" y="7" width="1" height="1" fill="#4fe35f"/><rect x="7" y="7" width="1" height="1" fill="#4fe35f"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="0" y="0" width="1" height="1" fill="#ffe23f"/><rect x="3" y="0" width="1" height="1" fill="#ffe23f"/><rect x="7" y="0" width="1" height="1" fill="#ffe23f"/><rect x="1" y="1" width="1" height="1" fill="#ffe23f"/><rect x="3" y="1" width="1" height="1" fill="#ffe23f"/><rect x="6" y="1" width="1" height="1" fill="#ffe23f"/><rect x="2" y="2" width="1" height="1" fill="#ffe23f"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffe23f"/><rect x="0" y="3" width="1" height="1" fill="#ffe23f"/><rect x="1" y="3" width="1" height="1" fill="#ffe23f"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="3" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="3" width="1" height="1" fill="#ffffff"/><rect x="6" y="3" width="1" height="1" fill="#ffe23f"/><rect x="7" y="3" width="1" height="1" fill="#ffe23f"/><rect x="2" y="4" width="1" height="1" fill="#ffe23f"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="4" y="4" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#ffe23f"/><rect x="1" y="5" width="1" height="1" fill="#ffe23f"/><rect x="4" y="5" width="1" height="1" fill="#ffe23f"/><rect x="6" y="5" width="1" height="1" fill="#ffe23f"/><rect x="0" y="6" width="1" height="1" fill="#ffe23f"/><rect x="4" y="6" width="1" height="1" fill="#ffe23f"/><rect x="7" y="6" width="1" height="1" fill="#ffe23f"/></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="2" y="0" width="1" height="1" fill="#ffffff"/><rect x="1" y="1" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="0" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="2" width="1" height="1" fill="#ffffff"/><rect x="2" y="2" width="1" height="1" fill="#ffffff"/><rect x="3" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="2" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="6" y="2" width="1" height="1" fill="#ffffff"/><rect x="1" y="3" width="1" height="1" fill="#ffffff"/><rect x="2" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="4" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="4" width="1" height="1" fill="#3fe3f2"/><rect x="1" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="2" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="3" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="4" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="5" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="7" y="5" width="1" height="1" fill="#3fe3f2"/><rect x="5" y="6" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="6" width="1" height="1" fill="#3fe3f2"/><rect x="6" y="7" width="1" height="1" fill="#3fe3f2"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 8 8" width="64" height="64" shape-rendering="crispEdges"><rect width="8" height="8" fill="#000000"/><rect x="2" y="0" width="1" height="1" fill="#ffffff"/><rect x="3" y="0" width="1" height="1" fill="#ffffff"/><rect x="4" y="0" width="1" height="1" fill="#ffffff"/><rect x="5" y="0" width="1" height="1" fill="#ffffff"/><rect x="1" y="1" width="1" height="1" fill="#ffffff"/><rect x="2" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="1" width="1" height="1" fill="#ffffff"/><rect x="6" y="1" width="1" height="1" fill="#ffffff"/><rect x="5" y="2" width="1" height="1" fill="#ffffff"/><rect x="6" y="2" width="1" height="1" fill="#ffffff"/><rect x="4" y="3" width="1" height="1" fill="#ffffff"/><rect x="5" y="3" width="1" height="1" fill="#ffffff"/><rect x="3" y="4" width="1" height="1" fill="#ffffff"/><rect x="4" y="4" width="1" height="1" fill="#ffffff"/><rect x="3" y="5" width="1" height="1" fill="#ffffff"/><rect x="4" y="5" width="1" height="1" fill="#ffffff"/><rect x="3" y="7" width="1" height="1" fill="#ffffff"/><rect x="4" y="7" width="1" height="1" fill="#ffffff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
// Runtime configuration. In the Docker image this file is generated from environment variables.
|
||||
// serverUrl: full URL of the game hub, e.g. "https://blocks.example.com/hub". Empty = "<this origin>/hub".
|
||||
window.__ACTIONBLOCKS_CONFIG__ = { serverUrl: "" };
|
||||
@@ -0,0 +1,13 @@
|
||||
.status {
|
||||
padding: 8px 16px;
|
||||
background: var(--color-danger);
|
||||
color: var(--color-bg);
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 64px 16px;
|
||||
text-align: center;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useGameConnection } from './net/useGameConnection';
|
||||
import { GamePage } from './pages/GamePage';
|
||||
import { HomePage } from './pages/HomePage';
|
||||
import { LobbyPage } from './pages/LobbyPage';
|
||||
import styles from './App.module.css';
|
||||
|
||||
const statusText = {
|
||||
connecting: 'Connecting to server...',
|
||||
reconnecting: 'Connection lost, reconnecting...',
|
||||
disconnected: 'Server unreachable, retrying...',
|
||||
connected: '',
|
||||
};
|
||||
|
||||
export function App() {
|
||||
const { status, playerId, lobby, match, flashes, actions } = useGameConnection();
|
||||
|
||||
let content;
|
||||
if (!lobby || !playerId) {
|
||||
content = <HomePage connected={status === 'connected'} actions={actions} />;
|
||||
} else if (match) {
|
||||
content = <GamePage match={match} lobby={lobby} playerId={playerId} flashes={flashes} actions={actions} />;
|
||||
} else if (lobby.phase === 'playing') {
|
||||
content = <div className={styles.loading}>Starting...</div>;
|
||||
} else {
|
||||
content = <LobbyPage lobby={lobby} playerId={playerId} actions={actions} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{status !== 'connected' && <div className={styles.status}>{statusText[status]}</div>}
|
||||
{content}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
.frame {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
border: var(--border);
|
||||
line-height: 0;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
border-color: var(--color-target);
|
||||
box-shadow: 0 0 0 3px var(--color-target);
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.dead .canvas {
|
||||
opacity: 0.35;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
.out {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
font-size: 1.6em;
|
||||
color: var(--color-danger);
|
||||
text-shadow: 3px 3px 0 #000;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react';
|
||||
import type { PlayerState } from '../net/types';
|
||||
import { drawBoard, setupCanvas } from '../render/draw';
|
||||
import styles from './BoardCanvas.module.css';
|
||||
|
||||
interface Props {
|
||||
state: PlayerState;
|
||||
width: number;
|
||||
height: number;
|
||||
cell: number;
|
||||
hideStack?: boolean;
|
||||
highlighted?: boolean;
|
||||
/** Rendered on top of the board (flashes, countdown, results). */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function BoardCanvas({ state, width, height, cell, hideStack, highlighted, children }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = setupCanvas(canvas, width * cell, height * cell);
|
||||
drawBoard(ctx, state, width, height, { cell, hideStack, showGrid: cell >= 14 });
|
||||
}, [state, width, height, cell, hideStack]);
|
||||
|
||||
return (
|
||||
<div className={[styles.frame, highlighted ? styles.highlighted : '', state.alive ? '' : styles.dead].join(' ')}>
|
||||
<canvas ref={canvasRef} className={styles.canvas} />
|
||||
{!state.alive && <div className={styles.out}>{state.place ? `#${state.place}` : 'OUT'}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
.bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 3px;
|
||||
align-items: center;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
border: 1px solid var(--color-dim);
|
||||
}
|
||||
|
||||
.first {
|
||||
border-color: var(--color-fg);
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: block;
|
||||
border: 1px dashed var(--color-dim);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { itemVisual } from '../theme/assets';
|
||||
import styles from './ItemBar.module.css';
|
||||
|
||||
interface Props {
|
||||
items: string[];
|
||||
/** Icon size in px. */
|
||||
size: number;
|
||||
slots?: number;
|
||||
}
|
||||
|
||||
/** A row of item icons; the leftmost item is used next. */
|
||||
export function ItemBar({ items, size, slots }: Props) {
|
||||
const count = Math.max(slots ?? 0, items.length);
|
||||
return (
|
||||
<div className={styles.bar} style={{ minHeight: size + 6 }}>
|
||||
{Array.from({ length: count }, (_, i) => {
|
||||
const id = items[i];
|
||||
if (!id) return <span key={i} className={styles.empty} style={{ width: size, height: size }} />;
|
||||
const visual = itemVisual(id);
|
||||
return (
|
||||
<img
|
||||
key={i}
|
||||
className={[styles.icon, i === 0 ? styles.first : ''].join(' ')}
|
||||
src={visual.icon}
|
||||
alt={visual.name}
|
||||
title={visual.name}
|
||||
width={size}
|
||||
height={size}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 12%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
line-height: 1.3;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.flash {
|
||||
padding: 8px 10px;
|
||||
border: 3px solid;
|
||||
background: #000;
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
text-align: center;
|
||||
animation: flash 1.4s steps(7) forwards;
|
||||
}
|
||||
|
||||
.small .flash {
|
||||
padding: 4px 5px;
|
||||
border-width: 2px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1.3);
|
||||
}
|
||||
15% {
|
||||
transform: scale(1);
|
||||
}
|
||||
20%,
|
||||
40%,
|
||||
60% {
|
||||
opacity: 1;
|
||||
}
|
||||
30%,
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ItemFlash } from '../net/useGameConnection';
|
||||
import styles from './ItemFlashOverlay.module.css';
|
||||
|
||||
/** Briefly shows the names of items that were used on a player, on top of their board. */
|
||||
export function ItemFlashOverlay({ flashes, small = false }: { flashes: ItemFlash[]; small?: boolean }) {
|
||||
if (flashes.length === 0) return null;
|
||||
return (
|
||||
<div className={[styles.overlay, small ? styles.small : ''].join(' ')}>
|
||||
{flashes.map((f) => (
|
||||
<div key={f.key} className={styles.flash} style={{ color: f.color, borderColor: f.color }}>
|
||||
{f.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
.logo,
|
||||
.small {
|
||||
margin: 0;
|
||||
font-weight: normal;
|
||||
letter-spacing: 2px;
|
||||
text-shadow: 4px 4px 0 var(--color-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: clamp(20px, 5vw, 40px);
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 14px;
|
||||
text-shadow: 2px 2px 0 var(--color-dim);
|
||||
}
|
||||
|
||||
.space {
|
||||
display: inline-block;
|
||||
width: 0.6em;
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 100%;
|
||||
height: 80px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.imageSmall {
|
||||
height: 28px;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { uiAssets } from '../theme/assets';
|
||||
import { blockColors } from '../theme/colors';
|
||||
import styles from './Logo.module.css';
|
||||
|
||||
const TEXT = 'ACTION BLOCKS';
|
||||
|
||||
export function Logo({ small = false }: { small?: boolean }) {
|
||||
if (uiAssets.logo) {
|
||||
return <img className={small ? styles.imageSmall : styles.image} src={uiAssets.logo} alt="Action Blocks" />;
|
||||
}
|
||||
let colorIndex = 0;
|
||||
return (
|
||||
<h1 className={small ? styles.small : styles.logo} aria-label="Action Blocks">
|
||||
{TEXT.split('').map((ch, i) =>
|
||||
ch === ' ' ? (
|
||||
<span key={i} className={styles.space} />
|
||||
) : (
|
||||
<span key={i} style={{ color: blockColors[(colorIndex++ % 7) + 1] }}>
|
||||
{ch}
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
</h1>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
.mini {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
display: inline-block;
|
||||
min-width: 18px;
|
||||
padding: 2px 4px;
|
||||
border: 2px solid var(--color-fg);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.selected .slot {
|
||||
background: var(--color-target);
|
||||
border-color: var(--color-target);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.items {
|
||||
border: 2px solid var(--color-dim);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { memo } from 'react';
|
||||
import type { PlayerState } from '../net/types';
|
||||
import type { ItemFlash } from '../net/useGameConnection';
|
||||
import { BoardCanvas } from './BoardCanvas';
|
||||
import { ItemBar } from './ItemBar';
|
||||
import { ItemFlashOverlay } from './ItemFlashOverlay';
|
||||
import styles from './MiniBoard.module.css';
|
||||
|
||||
interface Props {
|
||||
state: PlayerState;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Number key that targets this player, if any. */
|
||||
slot?: number;
|
||||
selected: boolean;
|
||||
flashes: ItemFlash[];
|
||||
cell: number;
|
||||
}
|
||||
|
||||
/** Small view of an opponent: board, name, target key and their current items. */
|
||||
export const MiniBoard = memo(function MiniBoard({ state, width, height, slot, selected, flashes, cell }: Props) {
|
||||
return (
|
||||
<div className={[styles.mini, selected ? styles.selected : ''].join(' ')}>
|
||||
<div className={styles.header}>
|
||||
{slot !== undefined && <span className={styles.slot}>{slot}</span>}
|
||||
<span className={styles.name} title={state.name}>
|
||||
{state.name}
|
||||
</span>
|
||||
</div>
|
||||
<BoardCanvas state={state} width={width} height={height} cell={cell} highlighted={selected}>
|
||||
<ItemFlashOverlay flashes={flashes} small />
|
||||
</BoardCanvas>
|
||||
<div className={styles.items} style={{ width: width * cell + 6 }}>
|
||||
<ItemBar items={state.inventory} size={Math.max(10, cell)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
.preview {
|
||||
border: var(--border);
|
||||
padding: 8px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 10px;
|
||||
color: var(--color-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.canvas {
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { PieceInfo } from '../net/types';
|
||||
import { drawPreviewPiece, setupCanvas } from '../render/draw';
|
||||
import styles from './PiecePreview.module.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
pieces: (PieceInfo | null)[];
|
||||
cell: number;
|
||||
dimmed?: boolean;
|
||||
}
|
||||
|
||||
const BOX_CELLS_W = 4;
|
||||
const BOX_CELLS_H = 2.6;
|
||||
|
||||
/** Shows the hold slot or the next queue. */
|
||||
export function PiecePreview({ title, pieces, cell, dimmed = false }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const boxW = BOX_CELLS_W * cell + cell;
|
||||
const boxH = BOX_CELLS_H * cell;
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = setupCanvas(canvas, boxW, boxH * Math.max(1, pieces.length));
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, boxW, boxH * Math.max(1, pieces.length));
|
||||
pieces.forEach((piece, i) => {
|
||||
if (piece) drawPreviewPiece(ctx, piece, 0, i * boxH, boxW, boxH, i === 0 ? cell : Math.round(cell * 0.8), dimmed);
|
||||
});
|
||||
}, [pieces, cell, boxW, boxH, dimmed]);
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<canvas ref={canvasRef} className={styles.canvas} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.button {
|
||||
border: var(--border);
|
||||
padding: 12px 18px;
|
||||
cursor: pointer;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 4px 4px 0 var(--color-dim);
|
||||
}
|
||||
|
||||
.button:active:not(:disabled) {
|
||||
transform: translate(2px, 2px);
|
||||
box-shadow: 2px 2px 0 var(--color-dim);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: var(--color-fg);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.primary:hover:not(:disabled) {
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import styles from './Button.module.css';
|
||||
|
||||
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'primary' | 'secondary';
|
||||
}
|
||||
|
||||
export function Button({ variant = 'primary', className, ...rest }: Props) {
|
||||
return <button className={[styles.button, styles[variant], className].filter(Boolean).join(' ')} {...rest} />;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
.panel {
|
||||
border: var(--border);
|
||||
padding: 16px;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import styles from './Panel.module.css';
|
||||
|
||||
export function Panel({ title, children, className }: { title?: string; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={[styles.panel, className].filter(Boolean).join(' ')}>
|
||||
{title && <h2 className={styles.title}>{title}</h2>}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { InputAction } from '../net/types';
|
||||
|
||||
/** Actions bound to keys. Movement actions auto-repeat while held (see useKeyboardInput). */
|
||||
export type BoundAction = 'MoveLeft' | 'MoveRight' | 'SoftDrop' | 'RotateCw' | 'RotateCcw' | 'HardDrop' | 'Hold' | 'UseItem';
|
||||
|
||||
/**
|
||||
* Key bindings by KeyboardEvent.code (physical key position, independent of layout).
|
||||
* KeyY is bound too so that "Z" works on QWERTZ keyboards.
|
||||
*/
|
||||
export const keyBindings: Record<BoundAction, string[]> = {
|
||||
MoveLeft: ['ArrowLeft', 'KeyA'],
|
||||
MoveRight: ['ArrowRight', 'KeyD'],
|
||||
SoftDrop: ['ArrowDown', 'KeyS'],
|
||||
RotateCw: ['ArrowUp', 'KeyW', 'KeyX', 'KeyE'],
|
||||
RotateCcw: ['KeyZ', 'KeyY', 'KeyQ', 'ControlLeft', 'ControlRight'],
|
||||
HardDrop: ['Space'],
|
||||
Hold: ['KeyC', 'ShiftLeft', 'ShiftRight'],
|
||||
UseItem: ['KeyF'],
|
||||
};
|
||||
|
||||
/** Number row keys select the item target: 1 = yourself, 2..9 = opponents in display order. */
|
||||
export const targetKeys = ['Digit1', 'Digit2', 'Digit3', 'Digit4', 'Digit5', 'Digit6', 'Digit7', 'Digit8', 'Digit9'];
|
||||
|
||||
/** Delayed auto shift and auto repeat rate in milliseconds. */
|
||||
export const DAS_MS = 167;
|
||||
export const ARR_MS = 33;
|
||||
|
||||
/** Human readable list for the help panel (plain ASCII, the pixel font has no arrow glyphs). */
|
||||
export const controlsHelp: { keys: string; action: string }[] = [
|
||||
{ keys: 'LEFT RIGHT / A D', action: 'Move' },
|
||||
{ keys: 'DOWN / S', action: 'Soft drop' },
|
||||
{ keys: 'SPACE', action: 'Hard drop' },
|
||||
{ keys: 'UP / W / X', action: 'Rotate right' },
|
||||
{ keys: 'Z / Q / CTRL', action: 'Rotate left' },
|
||||
{ keys: 'C / SHIFT', action: 'Hold' },
|
||||
{ keys: '1 - 9', action: 'Select target (1 = you)' },
|
||||
{ keys: 'F', action: 'Use item on target' },
|
||||
];
|
||||
|
||||
export const simpleActions: Partial<Record<BoundAction, InputAction>> = {
|
||||
RotateCw: 'RotateCw',
|
||||
RotateCcw: 'RotateCcw',
|
||||
HardDrop: 'HardDrop',
|
||||
Hold: 'Hold',
|
||||
UseItem: 'UseItem',
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
interface RuntimeConfig {
|
||||
serverUrl?: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__ACTIONBLOCKS_CONFIG__?: RuntimeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/** URL of the game hub. Priority: runtime config.js, then VITE_SERVER_URL, then "<origin>/hub". */
|
||||
export function getServerUrl(): string {
|
||||
const runtime = window.__ACTIONBLOCKS_CONFIG__?.serverUrl?.trim();
|
||||
if (runtime) return runtime;
|
||||
const buildTime = (import.meta.env.VITE_SERVER_URL as string | undefined)?.trim();
|
||||
if (buildTime) return buildTime;
|
||||
return `${window.location.origin}/hub`;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { ARR_MS, DAS_MS, keyBindings, simpleActions, targetKeys, type BoundAction } from '../config/keybindings';
|
||||
import type { InputAction } from '../net/types';
|
||||
|
||||
interface Options {
|
||||
enabled: boolean;
|
||||
onAction: (action: InputAction) => void;
|
||||
/** Called with 1-based target slot (1 = self). */
|
||||
onTarget: (slot: number) => void;
|
||||
}
|
||||
|
||||
const codeToAction = new Map<string, BoundAction>(
|
||||
(Object.entries(keyBindings) as [BoundAction, string[]][]).flatMap(([action, codes]) =>
|
||||
codes.map((code) => [code, action] as const),
|
||||
),
|
||||
);
|
||||
|
||||
type Direction = 'MoveLeft' | 'MoveRight';
|
||||
|
||||
/**
|
||||
* Translates keyboard events into game inputs. Left/right use delayed auto shift (DAS) with
|
||||
* auto repeat (ARR); when both are held, the most recently pressed direction wins.
|
||||
*/
|
||||
export function useKeyboardInput({ enabled, onAction, onTarget }: Options) {
|
||||
const handlers = useRef({ onAction, onTarget });
|
||||
handlers.current = { onAction, onTarget };
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
const heldDirections: Direction[] = [];
|
||||
const heldCodes = new Set<string>();
|
||||
let dasTimer: number | undefined;
|
||||
let arrTimer: number | undefined;
|
||||
let softDropKeys = 0;
|
||||
|
||||
const stopRepeat = () => {
|
||||
window.clearTimeout(dasTimer);
|
||||
window.clearInterval(arrTimer);
|
||||
dasTimer = arrTimer = undefined;
|
||||
};
|
||||
|
||||
const startRepeat = (direction: Direction) => {
|
||||
stopRepeat();
|
||||
handlers.current.onAction(direction);
|
||||
dasTimer = window.setTimeout(() => {
|
||||
arrTimer = window.setInterval(() => handlers.current.onAction(direction), ARR_MS);
|
||||
}, DAS_MS);
|
||||
};
|
||||
|
||||
const isTyping = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
return target?.tagName === 'INPUT' || target?.tagName === 'TEXTAREA';
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (isTyping(e)) return;
|
||||
|
||||
const slot = targetKeys.indexOf(e.code);
|
||||
if (slot >= 0) {
|
||||
e.preventDefault();
|
||||
if (!e.repeat) handlers.current.onTarget(slot + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
const action = codeToAction.get(e.code);
|
||||
if (!action) return;
|
||||
e.preventDefault();
|
||||
if (e.repeat || heldCodes.has(e.code)) return;
|
||||
heldCodes.add(e.code);
|
||||
|
||||
if (action === 'MoveLeft' || action === 'MoveRight') {
|
||||
heldDirections.push(action);
|
||||
startRepeat(action);
|
||||
} else if (action === 'SoftDrop') {
|
||||
if (softDropKeys++ === 0) handlers.current.onAction('SoftDropStart');
|
||||
} else {
|
||||
const simple = simpleActions[action];
|
||||
if (simple) handlers.current.onAction(simple);
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
if (!heldCodes.delete(e.code)) return;
|
||||
const action = codeToAction.get(e.code);
|
||||
|
||||
if (action === 'MoveLeft' || action === 'MoveRight') {
|
||||
const index = heldDirections.indexOf(action);
|
||||
if (index >= 0) heldDirections.splice(index, 1);
|
||||
const remaining = heldDirections[heldDirections.length - 1];
|
||||
if (remaining) startRepeat(remaining);
|
||||
else stopRepeat();
|
||||
} else if (action === 'SoftDrop') {
|
||||
softDropKeys = Math.max(0, softDropKeys - 1);
|
||||
if (softDropKeys === 0) handlers.current.onAction('SoftDropStop');
|
||||
}
|
||||
};
|
||||
|
||||
const releaseAll = () => {
|
||||
stopRepeat();
|
||||
heldDirections.length = 0;
|
||||
heldCodes.clear();
|
||||
if (softDropKeys > 0) handlers.current.onAction('SoftDropStop');
|
||||
softDropKeys = 0;
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('keyup', onKeyUp);
|
||||
window.addEventListener('blur', releaseAll);
|
||||
return () => {
|
||||
releaseAll();
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('keyup', onKeyUp);
|
||||
window.removeEventListener('blur', releaseAll);
|
||||
};
|
||||
}, [enabled]);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App';
|
||||
import { preloadAssets } from './theme/assets';
|
||||
import './theme/global.css';
|
||||
|
||||
preloadAssets();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { getServerUrl } from '../config/runtimeConfig';
|
||||
import type { InputAction, JoinResult, Lobby, MatchState } from './types';
|
||||
|
||||
export interface HubHandlers {
|
||||
onLobbyUpdated: (lobby: Lobby) => void;
|
||||
onMatchState: (state: MatchState) => void;
|
||||
onStatus: (status: ConnectionStatus) => void;
|
||||
}
|
||||
|
||||
export type ConnectionStatus = 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
|
||||
|
||||
/** Thin typed wrapper around the SignalR connection to the game hub. */
|
||||
export class HubClient {
|
||||
private readonly connection: signalR.HubConnection;
|
||||
private stopped = false;
|
||||
|
||||
constructor(private readonly handlers: HubHandlers) {
|
||||
this.connection = new signalR.HubConnectionBuilder()
|
||||
.withUrl(getServerUrl())
|
||||
.withAutomaticReconnect()
|
||||
.configureLogging(signalR.LogLevel.Warning)
|
||||
.build();
|
||||
|
||||
this.connection.on('LobbyUpdated', handlers.onLobbyUpdated);
|
||||
this.connection.on('MatchState', handlers.onMatchState);
|
||||
this.connection.onreconnecting(() => handlers.onStatus('reconnecting'));
|
||||
this.connection.onreconnected(() => handlers.onStatus('connected'));
|
||||
this.connection.onclose(() => {
|
||||
handlers.onStatus('disconnected');
|
||||
if (!this.stopped) this.scheduleStart();
|
||||
});
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
this.handlers.onStatus('connecting');
|
||||
try {
|
||||
await this.connection.start();
|
||||
this.handlers.onStatus('connected');
|
||||
} catch {
|
||||
this.handlers.onStatus('disconnected');
|
||||
this.scheduleStart();
|
||||
}
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true;
|
||||
void this.connection.stop();
|
||||
}
|
||||
|
||||
createLobby(name: string) {
|
||||
return this.invoke<JoinResult>('CreateLobby', name);
|
||||
}
|
||||
|
||||
joinLobby(code: string, name: string) {
|
||||
return this.invoke<JoinResult>('JoinLobby', code, name);
|
||||
}
|
||||
|
||||
leaveLobby() {
|
||||
return this.invoke<void>('LeaveLobby');
|
||||
}
|
||||
|
||||
startGame() {
|
||||
return this.invoke<void>('StartGame');
|
||||
}
|
||||
|
||||
/** Fire-and-forget input; ordering is preserved by the connection. */
|
||||
input(action: InputAction) {
|
||||
if (this.connection.state === signalR.HubConnectionState.Connected) {
|
||||
void this.connection.send('Input', action);
|
||||
}
|
||||
}
|
||||
|
||||
selectTarget(targetId: string) {
|
||||
if (this.connection.state === signalR.HubConnectionState.Connected) {
|
||||
void this.connection.send('SelectTarget', targetId);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleStart() {
|
||||
window.setTimeout(() => {
|
||||
if (!this.stopped && this.connection.state === signalR.HubConnectionState.Disconnected) void this.start();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
private async invoke<T>(method: string, ...args: unknown[]): Promise<T> {
|
||||
try {
|
||||
return await this.connection.invoke<T>(method, ...args);
|
||||
} catch (e) {
|
||||
throw new Error(cleanHubError(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanHubError(e: unknown): string {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
const marker = 'HubException: ';
|
||||
const index = message.indexOf(marker);
|
||||
return index >= 0 ? message.slice(index + marker.length) : 'Connection problem, please try again.';
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Mirrors the server DTOs in actionBlocks_server/src/ActionBlocks.Server/Dtos/Dtos.cs
|
||||
|
||||
export interface LobbyPlayer {
|
||||
id: string;
|
||||
name: string;
|
||||
isHost: boolean;
|
||||
}
|
||||
|
||||
export interface MatchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
place: number;
|
||||
score: number;
|
||||
lines: number;
|
||||
}
|
||||
|
||||
export interface Lobby {
|
||||
code: string;
|
||||
hostId: string;
|
||||
phase: 'waiting' | 'playing';
|
||||
maxPlayers: number;
|
||||
players: LobbyPlayer[];
|
||||
lastResults: MatchResult[] | null;
|
||||
}
|
||||
|
||||
export interface JoinResult {
|
||||
playerId: string;
|
||||
lobby: Lobby;
|
||||
}
|
||||
|
||||
export interface PieceInfo {
|
||||
type: number;
|
||||
itemId: string | null;
|
||||
itemIndex: number;
|
||||
}
|
||||
|
||||
export interface ItemCell {
|
||||
x: number;
|
||||
y: number;
|
||||
itemId: string;
|
||||
}
|
||||
|
||||
export interface ActivePieceState {
|
||||
type: number;
|
||||
cells: [number, number][];
|
||||
itemIndex: number;
|
||||
itemId: string | null;
|
||||
}
|
||||
|
||||
export interface PlayerState {
|
||||
id: string;
|
||||
name: string;
|
||||
alive: boolean;
|
||||
place: number | null;
|
||||
/** One character per visible cell, row by row: '0' empty, '1'-'7' piece colors, '8' garbage. */
|
||||
board: string;
|
||||
items: ItemCell[];
|
||||
active: ActivePieceState | null;
|
||||
ghost: [number, number][] | null;
|
||||
hold: PieceInfo | null;
|
||||
holdUsed: boolean;
|
||||
next: PieceInfo[];
|
||||
inventory: string[];
|
||||
score: number;
|
||||
lines: number;
|
||||
level: number;
|
||||
pendingGarbage: number;
|
||||
invisibleRemaining: number;
|
||||
}
|
||||
|
||||
export interface MatchEvent {
|
||||
type: 'itemUsed' | 'eliminated' | string;
|
||||
targetId?: string | null;
|
||||
itemId?: string | null;
|
||||
itemName?: string | null;
|
||||
playerId?: string | null;
|
||||
place?: number | null;
|
||||
}
|
||||
|
||||
export interface MatchState {
|
||||
phase: 'countdown' | 'running' | 'finished';
|
||||
countdown: number;
|
||||
winnerId: string | null;
|
||||
width: number;
|
||||
height: number;
|
||||
players: PlayerState[];
|
||||
events: MatchEvent[];
|
||||
}
|
||||
|
||||
/** Must match ActionBlocks.Game.InputAction on the server. */
|
||||
export type InputAction =
|
||||
| 'MoveLeft'
|
||||
| 'MoveRight'
|
||||
| 'RotateCw'
|
||||
| 'RotateCcw'
|
||||
| 'SoftDropStart'
|
||||
| 'SoftDropStop'
|
||||
| 'HardDrop'
|
||||
| 'Hold'
|
||||
| 'UseItem';
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { itemVisual } from '../theme/assets';
|
||||
import { HubClient, type ConnectionStatus } from './hubClient';
|
||||
import type { InputAction, Lobby, MatchState } from './types';
|
||||
|
||||
/** A short message shown on top of a player's board when an item hits them. */
|
||||
export interface ItemFlash {
|
||||
key: number;
|
||||
targetId: string;
|
||||
text: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
const FLASH_MS = 1400;
|
||||
|
||||
export function useGameConnection() {
|
||||
const [status, setStatus] = useState<ConnectionStatus>('connecting');
|
||||
const [playerId, setPlayerId] = useState<string | null>(null);
|
||||
const [lobby, setLobby] = useState<Lobby | null>(null);
|
||||
const [match, setMatch] = useState<MatchState | null>(null);
|
||||
const [flashes, setFlashes] = useState<ItemFlash[]>([]);
|
||||
const clientRef = useRef<HubClient | null>(null);
|
||||
const flashKey = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const client = new HubClient({
|
||||
onLobbyUpdated: (next) => {
|
||||
setLobby(next);
|
||||
if (next.phase === 'waiting') setMatch(null);
|
||||
},
|
||||
onMatchState: (state) => {
|
||||
setMatch(state);
|
||||
const newFlashes = state.events
|
||||
.filter((e) => e.type === 'itemUsed' && e.targetId)
|
||||
.map<ItemFlash>((e) => ({
|
||||
key: ++flashKey.current,
|
||||
targetId: e.targetId!,
|
||||
text: e.itemName ?? itemVisual(e.itemId ?? '').name,
|
||||
color: itemVisual(e.itemId ?? '').color,
|
||||
}));
|
||||
if (newFlashes.length > 0) {
|
||||
setFlashes((current) => [...current, ...newFlashes]);
|
||||
const keys = new Set(newFlashes.map((f) => f.key));
|
||||
window.setTimeout(() => setFlashes((current) => current.filter((f) => !keys.has(f.key))), FLASH_MS);
|
||||
}
|
||||
},
|
||||
onStatus: (s) => {
|
||||
setStatus(s);
|
||||
// The server forgets players whose connection drops, so local lobby state is stale.
|
||||
if (s !== 'connected') {
|
||||
setLobby(null);
|
||||
setMatch(null);
|
||||
setPlayerId(null);
|
||||
}
|
||||
},
|
||||
});
|
||||
clientRef.current = client;
|
||||
void client.start();
|
||||
return () => client.stop();
|
||||
}, []);
|
||||
|
||||
const createLobby = useCallback(async (name: string) => {
|
||||
const result = await clientRef.current!.createLobby(name);
|
||||
setPlayerId(result.playerId);
|
||||
setLobby(result.lobby);
|
||||
}, []);
|
||||
|
||||
const joinLobby = useCallback(async (code: string, name: string) => {
|
||||
const result = await clientRef.current!.joinLobby(code, name);
|
||||
setPlayerId(result.playerId);
|
||||
setLobby(result.lobby);
|
||||
}, []);
|
||||
|
||||
const leaveLobby = useCallback(async () => {
|
||||
await clientRef.current?.leaveLobby();
|
||||
setLobby(null);
|
||||
setMatch(null);
|
||||
setPlayerId(null);
|
||||
}, []);
|
||||
|
||||
const startGame = useCallback(() => clientRef.current!.startGame(), []);
|
||||
const sendInput = useCallback((action: InputAction) => clientRef.current?.input(action), []);
|
||||
const selectTarget = useCallback((targetId: string) => clientRef.current?.selectTarget(targetId), []);
|
||||
|
||||
const actions = useMemo(
|
||||
() => ({ createLobby, joinLobby, leaveLobby, startGame, sendInput, selectTarget }),
|
||||
[createLobby, joinLobby, leaveLobby, startGame, sendInput, selectTarget],
|
||||
);
|
||||
|
||||
return { status, playerId, lobby, match, flashes, actions };
|
||||
}
|
||||
|
||||
export type GameActions = ReturnType<typeof useGameConnection>['actions'];
|
||||
@@ -0,0 +1,157 @@
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 12px 16px 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.lobbyCode {
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.arena {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.own {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 110px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
border: var(--border);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
color: var(--color-muted);
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.slotHint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.slot {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
padding: 2px 5px;
|
||||
border: 2px solid var(--color-fg);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.slotSelected {
|
||||
background: var(--color-target);
|
||||
border-color: var(--color-target);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
||||
.boardColumn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.boardRow {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.garbageMeter {
|
||||
position: relative;
|
||||
width: 8px;
|
||||
border: 2px solid var(--color-dim);
|
||||
box-sizing: content-box;
|
||||
}
|
||||
|
||||
.garbageFill {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--color-danger);
|
||||
}
|
||||
|
||||
.inventory {
|
||||
align-self: flex-end;
|
||||
border: var(--border);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.useHint {
|
||||
padding: 4px 3px 0;
|
||||
font-size: 8px;
|
||||
color: var(--color-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
line-height: 1.4;
|
||||
text-align: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
font-size: 64px;
|
||||
color: var(--color-accent);
|
||||
text-shadow: 5px 5px 0 var(--color-dim);
|
||||
}
|
||||
|
||||
.result {
|
||||
padding: 0 12px;
|
||||
font-size: 20px;
|
||||
color: var(--color-accent);
|
||||
text-shadow: 3px 3px 0 var(--color-dim);
|
||||
}
|
||||
|
||||
.opponents {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
gap: 18px;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.spectator {
|
||||
padding: 20px;
|
||||
border: var(--border);
|
||||
color: var(--color-muted);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { BoardCanvas } from '../components/BoardCanvas';
|
||||
import { ItemBar } from '../components/ItemBar';
|
||||
import { ItemFlashOverlay } from '../components/ItemFlashOverlay';
|
||||
import { Logo } from '../components/Logo';
|
||||
import { MiniBoard } from '../components/MiniBoard';
|
||||
import { PiecePreview } from '../components/PiecePreview';
|
||||
import { useKeyboardInput } from '../input/useKeyboardInput';
|
||||
import type { Lobby, MatchState } from '../net/types';
|
||||
import type { GameActions, ItemFlash } from '../net/useGameConnection';
|
||||
import { itemVisual } from '../theme/assets';
|
||||
import styles from './GamePage.module.css';
|
||||
|
||||
interface Props {
|
||||
match: MatchState;
|
||||
lobby: Lobby;
|
||||
playerId: string;
|
||||
flashes: ItemFlash[];
|
||||
actions: GameActions;
|
||||
}
|
||||
|
||||
const INVENTORY_SLOTS = 10;
|
||||
|
||||
function useWindowHeight() {
|
||||
const [height, setHeight] = useState(window.innerHeight);
|
||||
useEffect(() => {
|
||||
const onResize = () => setHeight(window.innerHeight);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
return height;
|
||||
}
|
||||
|
||||
export function GamePage({ match, lobby, playerId, flashes, actions }: Props) {
|
||||
const me = match.players.find((p) => p.id === playerId);
|
||||
const opponents = useMemo(() => match.players.filter((p) => p.id !== playerId), [match.players, playerId]);
|
||||
const [targetId, setTargetId] = useState(playerId);
|
||||
const windowHeight = useWindowHeight();
|
||||
|
||||
const cell = Math.max(16, Math.min(32, Math.floor((windowHeight - 170) / match.height)));
|
||||
const miniCell = opponents.length <= 2 ? 14 : opponents.length <= 4 ? 12 : 9;
|
||||
|
||||
// Fall back to self when the selected opponent is gone or eliminated (the server does the same).
|
||||
const target = match.players.find((p) => p.id === targetId);
|
||||
useEffect(() => {
|
||||
if (targetId !== playerId && (!target || !target.alive)) setTargetId(playerId);
|
||||
}, [target, targetId, playerId]);
|
||||
|
||||
const onTarget = useCallback(
|
||||
(slot: number) => {
|
||||
const next = slot === 1 ? me : opponents[slot - 2];
|
||||
if (!next || !next.alive) return;
|
||||
setTargetId(next.id);
|
||||
actions.selectTarget(next.id);
|
||||
},
|
||||
[me, opponents, actions],
|
||||
);
|
||||
|
||||
useKeyboardInput({
|
||||
enabled: !!me?.alive && match.phase !== 'finished',
|
||||
onAction: actions.sendInput,
|
||||
onTarget,
|
||||
});
|
||||
|
||||
const flashesFor = (id: string) => flashes.filter((f) => f.targetId === id);
|
||||
const winner = match.players.find((p) => p.id === match.winnerId);
|
||||
const nextItem = me?.inventory[0];
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<Logo small />
|
||||
<span className={styles.lobbyCode}>LOBBY {lobby.code}</span>
|
||||
</header>
|
||||
|
||||
<div className={styles.arena}>
|
||||
{me ? (
|
||||
<section className={styles.own}>
|
||||
<div className={styles.side}>
|
||||
<PiecePreview title="HOLD" pieces={[me.hold]} cell={Math.round(cell * 0.7)} dimmed={me.holdUsed} />
|
||||
<div className={styles.stats}>
|
||||
<Stat label="SCORE" value={me.score} />
|
||||
<Stat label="LINES" value={me.lines} />
|
||||
<Stat label="LEVEL" value={me.level} />
|
||||
</div>
|
||||
<div className={styles.slotHint}>
|
||||
<span className={[styles.slot, targetId === playerId ? styles.slotSelected : ''].join(' ')}>1</span>
|
||||
YOU
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.boardColumn}>
|
||||
<div className={styles.boardRow}>
|
||||
<div className={styles.garbageMeter} style={{ height: match.height * cell }}>
|
||||
<div
|
||||
className={styles.garbageFill}
|
||||
style={{ height: Math.min(me.pendingGarbage, match.height) * cell }}
|
||||
/>
|
||||
</div>
|
||||
<BoardCanvas
|
||||
state={me}
|
||||
width={match.width}
|
||||
height={match.height}
|
||||
cell={cell}
|
||||
hideStack={me.invisibleRemaining > 0}
|
||||
highlighted={targetId === playerId}
|
||||
>
|
||||
<ItemFlashOverlay flashes={flashesFor(me.id)} />
|
||||
{match.phase === 'countdown' && (
|
||||
<div className={styles.overlay}>
|
||||
<span className={styles.countdown}>{Math.ceil(match.countdown)}</span>
|
||||
</div>
|
||||
)}
|
||||
{match.phase === 'finished' && (
|
||||
<div className={styles.overlay}>
|
||||
<span className={styles.result}>
|
||||
{match.winnerId === playerId ? 'YOU WIN!' : `${winner?.name ?? '?'} WINS`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</BoardCanvas>
|
||||
</div>
|
||||
<div className={styles.inventory} style={{ width: match.width * cell + 6 }}>
|
||||
<ItemBar items={me.inventory} size={Math.max(16, Math.round(cell * 0.85))} slots={INVENTORY_SLOTS} />
|
||||
<div className={styles.useHint}>
|
||||
F: {nextItem ? itemVisual(nextItem).name : '-'} {'>'} {target && target.id !== playerId ? target.name : 'YOU'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.side}>
|
||||
<PiecePreview title="NEXT" pieces={me.next} cell={Math.round(cell * 0.7)} />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<div className={styles.spectator}>SPECTATING</div>
|
||||
)}
|
||||
|
||||
{opponents.length > 0 && (
|
||||
<section className={styles.opponents}>
|
||||
{opponents.map((p, i) => (
|
||||
<MiniBoard
|
||||
key={p.id}
|
||||
state={p}
|
||||
width={match.width}
|
||||
height={match.height}
|
||||
cell={me ? miniCell : 14}
|
||||
slot={me && i < 8 ? i + 2 : undefined}
|
||||
selected={p.id === targetId}
|
||||
flashes={flashesFor(p.id)}
|
||||
/>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className={styles.stat}>
|
||||
<div className={styles.statLabel}>{label}</div>
|
||||
<div className={styles.statValue}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
margin: 0 0 12px;
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
gap: 20px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0 0 16px;
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: var(--border);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.joinForm {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.codeInput {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
padding: 10px 14px;
|
||||
border: 3px solid var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { Logo } from '../components/Logo';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Panel } from '../components/ui/Panel';
|
||||
import type { GameActions } from '../net/useGameConnection';
|
||||
import styles from './HomePage.module.css';
|
||||
|
||||
const NAME_KEY = 'actionblocks.name';
|
||||
|
||||
function loadName(): string {
|
||||
try {
|
||||
return localStorage.getItem(NAME_KEY) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function saveName(name: string) {
|
||||
try {
|
||||
localStorage.setItem(NAME_KEY, name);
|
||||
} catch {
|
||||
// Storage may be unavailable (private mode); the name is just not remembered.
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
connected: boolean;
|
||||
actions: GameActions;
|
||||
}
|
||||
|
||||
export function HomePage({ connected, actions }: Props) {
|
||||
const [name, setName] = useState(loadName);
|
||||
const [code, setCode] = useState(() => new URLSearchParams(window.location.search).get('code')?.toUpperCase() ?? '');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const run = async (action: () => Promise<void>) => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
saveName(name.trim());
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate = () => run(() => actions.createLobby(name));
|
||||
const onJoin = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (code.trim()) void run(() => actions.joinLobby(code, name));
|
||||
};
|
||||
|
||||
const disabled = !connected || busy;
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<Logo />
|
||||
<p className={styles.tagline}>Stack. Clear. Collect. Strike.</p>
|
||||
|
||||
<Panel title="Your name" className={styles.panel}>
|
||||
<input
|
||||
className={styles.input}
|
||||
value={name}
|
||||
maxLength={16}
|
||||
placeholder="PLAYER"
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<div className={styles.columns}>
|
||||
<Panel title="New game" className={styles.panel}>
|
||||
<p className={styles.hint}>Open a lobby and share its code.</p>
|
||||
<Button onClick={onCreate} disabled={disabled}>
|
||||
Create lobby
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Join game" className={styles.panel}>
|
||||
<form onSubmit={onJoin} className={styles.joinForm}>
|
||||
<input
|
||||
className={[styles.input, styles.codeInput].join(' ')}
|
||||
value={code}
|
||||
maxLength={6}
|
||||
placeholder="CODE"
|
||||
onChange={(e) => setCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, ''))}
|
||||
/>
|
||||
<Button type="submit" variant="secondary" disabled={disabled || code.length === 0}>
|
||||
Join
|
||||
</Button>
|
||||
</form>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
.page {
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 48px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.codePanel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-size: clamp(28px, 8vw, 56px);
|
||||
letter-spacing: 10px;
|
||||
color: var(--color-accent);
|
||||
text-shadow: 4px 4px 0 var(--color-dim);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.players {
|
||||
margin: 0 0 20px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
.players li {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.players li span + span {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.me {
|
||||
color: var(--color-target);
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 1px 4px;
|
||||
border: 2px solid currentColor;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.start {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.waiting,
|
||||
.hint {
|
||||
color: var(--color-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--color-danger);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.results {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.results td {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.winner {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.controls div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 3px 0;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.controls dt {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.controls dd {
|
||||
margin: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.items li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.items img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.itemDescription {
|
||||
color: var(--color-muted);
|
||||
font-size: 8px;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { Logo } from '../components/Logo';
|
||||
import { Button } from '../components/ui/Button';
|
||||
import { Panel } from '../components/ui/Panel';
|
||||
import { controlsHelp } from '../config/keybindings';
|
||||
import type { Lobby } from '../net/types';
|
||||
import type { GameActions } from '../net/useGameConnection';
|
||||
import { itemVisuals } from '../theme/assets';
|
||||
import styles from './LobbyPage.module.css';
|
||||
|
||||
interface Props {
|
||||
lobby: Lobby;
|
||||
playerId: string;
|
||||
actions: GameActions;
|
||||
}
|
||||
|
||||
export function LobbyPage({ lobby, playerId, actions }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isHost = lobby.hostId === playerId;
|
||||
|
||||
const copyLink = async () => {
|
||||
const url = `${window.location.origin}${window.location.pathname}?code=${lobby.code}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
setError(url);
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await actions.startGame();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className={styles.page}>
|
||||
<header className={styles.header}>
|
||||
<Logo small />
|
||||
<Button variant="secondary" onClick={() => void actions.leaveLobby()}>
|
||||
Leave
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<Panel title="Lobby code" className={styles.codePanel}>
|
||||
<div className={styles.code}>{lobby.code}</div>
|
||||
<Button variant="secondary" onClick={copyLink}>
|
||||
{copied ? 'Copied!' : 'Copy invite link'}
|
||||
</Button>
|
||||
</Panel>
|
||||
|
||||
<div className={styles.grid}>
|
||||
<Panel title={`Players ${lobby.players.length}/${lobby.maxPlayers}`}>
|
||||
<ol className={styles.players}>
|
||||
{lobby.players.map((p) => (
|
||||
<li key={p.id} className={p.id === playerId ? styles.me : ''}>
|
||||
<span>{p.name}</span>
|
||||
{p.isHost && <span className={styles.badge}>HOST</span>}
|
||||
{p.id === playerId && <span className={styles.badge}>YOU</span>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
{isHost ? (
|
||||
<Button onClick={start} className={styles.start}>
|
||||
Start game
|
||||
</Button>
|
||||
) : (
|
||||
<p className={styles.waiting}>Waiting for the host to start...</p>
|
||||
)}
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
</Panel>
|
||||
|
||||
{lobby.lastResults && (
|
||||
<Panel title="Last round">
|
||||
<table className={styles.results}>
|
||||
<tbody>
|
||||
{lobby.lastResults.map((r) => (
|
||||
<tr key={r.id}>
|
||||
<td className={r.place === 1 ? styles.winner : ''}>#{r.place}</td>
|
||||
<td>{r.name}</td>
|
||||
<td className={styles.num}>{r.score}</td>
|
||||
<td className={styles.num}>{r.lines}L</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="Controls">
|
||||
<dl className={styles.controls}>
|
||||
{controlsHelp.map((c) => (
|
||||
<div key={c.action}>
|
||||
<dt>{c.keys}</dt>
|
||||
<dd>{c.action}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Items">
|
||||
<p className={styles.hint}>Items hide inside blocks. Clear their line to collect them.</p>
|
||||
<ul className={styles.items}>
|
||||
{Object.entries(itemVisuals).map(([id, v]) => (
|
||||
<li key={id}>
|
||||
<img src={v.icon} alt="" width={20} height={20} />
|
||||
<div>
|
||||
<div style={{ color: v.color }}>{v.name}</div>
|
||||
<div className={styles.itemDescription}>{v.description}</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { PieceInfo, PlayerState } from '../net/types';
|
||||
import { getImage, itemVisual } from '../theme/assets';
|
||||
import { blockColors, boardColors } from '../theme/colors';
|
||||
import { pieceShapes } from './shapes';
|
||||
|
||||
/** Prepares a canvas for crisp pixel drawing at the given CSS size. */
|
||||
export function setupCanvas(canvas: HTMLCanvasElement, cssWidth: number, cssHeight: number): CanvasRenderingContext2D {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const w = Math.round(cssWidth * dpr);
|
||||
const h = Math.round(cssHeight * dpr);
|
||||
if (canvas.width !== w || canvas.height !== h) {
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
canvas.style.width = `${cssWidth}px`;
|
||||
canvas.style.height = `${cssHeight}px`;
|
||||
}
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function shade(hex: string, amount: number): string {
|
||||
const n = parseInt(hex.slice(1), 16);
|
||||
const adjust = (c: number) => Math.max(0, Math.min(255, Math.round(amount > 0 ? c + (255 - c) * amount : c * (1 + amount))));
|
||||
const r = adjust((n >> 16) & 255);
|
||||
const g = adjust((n >> 8) & 255);
|
||||
const b = adjust(n & 255);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
/** A single block in a simple retro bevel style. */
|
||||
export function drawBlock(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) {
|
||||
const edge = Math.max(1, Math.floor(size / 7));
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, size, size);
|
||||
ctx.fillStyle = shade(color, 0.45);
|
||||
ctx.fillRect(x, y, size, edge);
|
||||
ctx.fillRect(x, y, edge, size);
|
||||
ctx.fillStyle = shade(color, -0.45);
|
||||
ctx.fillRect(x, y + size - edge, size, edge);
|
||||
ctx.fillRect(x + size - edge, y, edge, size);
|
||||
}
|
||||
|
||||
export function drawItemIcon(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, itemId: string) {
|
||||
const visual = itemVisual(itemId);
|
||||
const image = getImage(visual.icon);
|
||||
const inset = Math.max(1, Math.floor(size / 8));
|
||||
if (image.complete && image.naturalWidth > 0) {
|
||||
ctx.drawImage(image, x + inset, y + inset, size - inset * 2, size - inset * 2);
|
||||
} else {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(x + inset, y + inset, size - inset * 2, size - inset * 2);
|
||||
ctx.fillStyle = visual.color;
|
||||
ctx.fillRect(x + size / 3, y + size / 3, size / 3, size / 3);
|
||||
}
|
||||
}
|
||||
|
||||
function drawGhostCell(ctx: CanvasRenderingContext2D, x: number, y: number, size: number, color: string) {
|
||||
ctx.globalAlpha = boardColors.ghostAlpha;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x, y, size, size);
|
||||
ctx.globalAlpha = 1;
|
||||
const edge = Math.max(1, Math.floor(size / 10));
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = edge;
|
||||
ctx.strokeRect(x + edge / 2, y + edge / 2, size - edge, size - edge);
|
||||
}
|
||||
|
||||
export interface BoardDrawOptions {
|
||||
cell: number;
|
||||
/** Hide locked cells and the ghost piece (blackout item). */
|
||||
hideStack?: boolean;
|
||||
showGhost?: boolean;
|
||||
showGrid?: boolean;
|
||||
}
|
||||
|
||||
export function drawBoard(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: PlayerState,
|
||||
width: number,
|
||||
height: number,
|
||||
{ cell, hideStack = false, showGhost = true, showGrid = true }: BoardDrawOptions,
|
||||
) {
|
||||
ctx.fillStyle = boardColors.background;
|
||||
ctx.fillRect(0, 0, width * cell, height * cell);
|
||||
|
||||
if (showGrid) {
|
||||
ctx.fillStyle = boardColors.grid;
|
||||
for (let x = 1; x < width; x++) ctx.fillRect(x * cell, 0, 1, height * cell);
|
||||
for (let y = 1; y < height; y++) ctx.fillRect(0, y * cell, width * cell, 1);
|
||||
}
|
||||
|
||||
if (!hideStack) {
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
const value = state.board.charCodeAt(y * width + x) - 48;
|
||||
if (value > 0) drawBlock(ctx, x * cell, y * cell, cell, blockColors[value] ?? '#fff');
|
||||
}
|
||||
}
|
||||
for (const item of state.items) drawItemIcon(ctx, item.x * cell, item.y * cell, cell, item.itemId);
|
||||
}
|
||||
|
||||
const active = state.active;
|
||||
if (!active) return;
|
||||
const color = blockColors[active.type] ?? '#fff';
|
||||
|
||||
if (showGhost && !hideStack && state.ghost) {
|
||||
const activeKeys = new Set(active.cells.map(([x, y]) => `${x},${y}`));
|
||||
for (const [x, y] of state.ghost) {
|
||||
if (y >= 0 && !activeKeys.has(`${x},${y}`)) drawGhostCell(ctx, x * cell, y * cell, cell, color);
|
||||
}
|
||||
}
|
||||
|
||||
active.cells.forEach(([x, y], index) => {
|
||||
if (y < 0) return;
|
||||
drawBlock(ctx, x * cell, y * cell, cell, color);
|
||||
if (active.itemId && index === active.itemIndex) drawItemIcon(ctx, x * cell, y * cell, cell, active.itemId);
|
||||
});
|
||||
}
|
||||
|
||||
/** Draws a queued/held piece centered in a box of boxCells x 2 cells. */
|
||||
export function drawPreviewPiece(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
piece: PieceInfo,
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
boxWidth: number,
|
||||
boxHeight: number,
|
||||
cell: number,
|
||||
dimmed = false,
|
||||
) {
|
||||
const shape = pieceShapes[piece.type];
|
||||
if (!shape) return;
|
||||
const w = Math.max(...shape.map(([x]) => x)) + 1;
|
||||
const h = Math.max(...shape.map(([, y]) => y)) + 1;
|
||||
const ox = offsetX + (boxWidth - w * cell) / 2;
|
||||
const oy = offsetY + (boxHeight - h * cell) / 2;
|
||||
const color = dimmed ? '#555555' : (blockColors[piece.type] ?? '#fff');
|
||||
shape.forEach(([x, y], index) => {
|
||||
const px = Math.round(ox + x * cell);
|
||||
const py = Math.round(oy + y * cell);
|
||||
drawBlock(ctx, px, py, cell, color);
|
||||
if (piece.itemId && index === piece.itemIndex) drawItemIcon(ctx, px, py, cell, piece.itemId);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Spawn-orientation shapes by piece type (matches PieceDefinitions on the server), used for previews. */
|
||||
export const pieceShapes: Record<number, [number, number][]> = {
|
||||
1: [[0, 0], [1, 0], [2, 0], [3, 0]],
|
||||
2: [[0, 0], [0, 1], [1, 1], [2, 1]],
|
||||
3: [[2, 0], [0, 1], [1, 1], [2, 1]],
|
||||
4: [[0, 0], [1, 0], [0, 1], [1, 1]],
|
||||
5: [[1, 0], [2, 0], [0, 1], [1, 1]],
|
||||
6: [[0, 0], [1, 0], [1, 1], [2, 1]],
|
||||
7: [[1, 0], [0, 1], [1, 1], [2, 1]],
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Central registry of every graphic the game uses. Replace the files in /public/assets
|
||||
* (or point these paths elsewhere) to change the look without touching components.
|
||||
*/
|
||||
|
||||
export interface ItemVisual {
|
||||
/** Icon drawn on board cells, in inventories and item bars. */
|
||||
icon: string;
|
||||
/** Fallback name, the server sends the display name with each use. */
|
||||
name: string;
|
||||
/** Accent color used for flashes and as icon fallback. */
|
||||
color: string;
|
||||
/** Short explanation for the lobby help panel. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** Keyed by the server item id (IItem.Id). Add an entry here when adding an item on the server. */
|
||||
export const itemVisuals: Record<string, ItemVisual> = {
|
||||
add_line: {
|
||||
icon: '/assets/items/add_line.svg',
|
||||
name: 'Add Line',
|
||||
color: '#ff4256',
|
||||
description: 'Pushes a garbage line into the target field.',
|
||||
},
|
||||
clear_line: {
|
||||
icon: '/assets/items/clear_line.svg',
|
||||
name: 'Clear Line',
|
||||
color: '#4fe35f',
|
||||
description: 'Removes the bottom line of the target.',
|
||||
},
|
||||
nuke: {
|
||||
icon: '/assets/items/nuke.svg',
|
||||
name: 'Wipe Out',
|
||||
color: '#ffe23f',
|
||||
description: 'Clears every placed block of the target.',
|
||||
},
|
||||
swap: {
|
||||
icon: '/assets/items/swap.svg',
|
||||
name: 'Switch',
|
||||
color: '#3fe3f2',
|
||||
description: 'Trades your whole field with the target.',
|
||||
},
|
||||
blind: {
|
||||
icon: '/assets/items/blind.svg',
|
||||
name: 'Blackout',
|
||||
color: '#c452ff',
|
||||
description: 'Hides placed blocks and the drop shadow for 4 seconds.',
|
||||
},
|
||||
};
|
||||
|
||||
export const unknownItem: ItemVisual = {
|
||||
icon: '/assets/items/unknown.svg',
|
||||
name: 'Item',
|
||||
color: '#ffffff',
|
||||
description: '',
|
||||
};
|
||||
|
||||
export const uiAssets = {
|
||||
/** Optional logo image. When null the text logo is rendered. */
|
||||
logo: null as string | null,
|
||||
favicon: '/assets/favicon.svg',
|
||||
};
|
||||
|
||||
export function itemVisual(id: string): ItemVisual {
|
||||
return itemVisuals[id] ?? unknownItem;
|
||||
}
|
||||
|
||||
const imageCache = new Map<string, HTMLImageElement>();
|
||||
|
||||
/** Returns a (possibly still loading) image for canvas rendering. */
|
||||
export function getImage(src: string): HTMLImageElement {
|
||||
let image = imageCache.get(src);
|
||||
if (!image) {
|
||||
image = new Image();
|
||||
image.src = src;
|
||||
imageCache.set(src, image);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
export function preloadAssets(): void {
|
||||
Object.values(itemVisuals).forEach((v) => getImage(v.icon));
|
||||
getImage(unknownItem.icon);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Block colors by cell value (see PlayerState.board). Swap these to restyle the blocks.
|
||||
* Index 0 is the empty cell, 8 is garbage.
|
||||
*/
|
||||
export const blockColors: Record<number, string> = {
|
||||
1: '#3fe3f2',
|
||||
2: '#3a66ff',
|
||||
3: '#ff9b26',
|
||||
4: '#ffe23f',
|
||||
5: '#4fe35f',
|
||||
6: '#ff4256',
|
||||
7: '#c452ff',
|
||||
8: '#8c8c8c',
|
||||
};
|
||||
|
||||
export const boardColors = {
|
||||
background: '#000000',
|
||||
grid: '#141414',
|
||||
border: '#ffffff',
|
||||
ghostAlpha: 0.28,
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
:root {
|
||||
--color-bg: #000000;
|
||||
--color-fg: #ffffff;
|
||||
--color-muted: #9a9a9a;
|
||||
--color-dim: #3a3a3a;
|
||||
--color-accent: #ffe23f;
|
||||
--color-danger: #ff4256;
|
||||
--color-ok: #4fe35f;
|
||||
--color-target: #3fe3f2;
|
||||
--font-pixel: 'Press Start 2P', ui-monospace, 'Courier New', monospace;
|
||||
--border: 3px solid var(--color-fg);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
font-family: var(--font-pixel);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
image-rendering: pixelated;
|
||||
-webkit-font-smoothing: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--color-fg);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client", "node"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
// Dev server forwards /hub to a locally running game server (dotnet run).
|
||||
const devServer = process.env.DEV_SERVER_URL ?? 'http://localhost:5080';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
// Hashed bundles go to /static; /assets holds the exchangeable graphics from public/.
|
||||
assetsDir: 'static',
|
||||
},
|
||||
css: {
|
||||
modules: {
|
||||
localsConvention: 'camelCaseOnly',
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/hub': { target: devServer, ws: true, changeOrigin: true },
|
||||
},
|
||||
},
|
||||
});
|
||||