init
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user