This commit is contained in:
2026-10-23 19:23:03 +02:00
parent 8c8240de63
commit 19428550ca
90 changed files with 6912 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
node_modules
dist
Dockerfile
.dockerignore
+3
View File
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
+17
View File
@@ -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
+10
View File
@@ -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
+35
View File
@@ -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;
}
}
+18
View File
@@ -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>
File diff suppressed because it is too large Load Diff
+25
View File
@@ -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

+3
View File
@@ -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: "" };
+13
View File
@@ -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);
}
+34
View File
@@ -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]);
}
+13
View File
@@ -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>,
);
+100
View File
@@ -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.';
}
+100
View File
@@ -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);
}
+167
View File
@@ -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);
}
+101
View File
@@ -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>
);
}
+146
View File
@@ -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);
});
}
+10
View File
@@ -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]],
};
+84
View File
@@ -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);
}
+21
View File
@@ -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,
};
+46
View File
@@ -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);
}
+18
View File
@@ -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"]
}
+23
View File
@@ -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 },
},
},
});