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
@@ -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>
);
}