added transformation item

This commit is contained in:
2026-10-25 22:08:09 +01:00
parent 0fb223cdee
commit 6184cdba0a
27 changed files with 307 additions and 52 deletions
@@ -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="1" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="1" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="2" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="2" width="1" height="1" fill="#ff6fd8"/><rect x="1" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="2" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="5" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="6" y="3" width="1" height="1" fill="#ff6fd8"/><rect x="1" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="2" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="5" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="6" y="4" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="5" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="5" width="1" height="1" fill="#ff6fd8"/><rect x="3" y="6" width="1" height="1" fill="#ff6fd8"/><rect x="4" y="6" width="1" height="1" fill="#ff6fd8"/><rect x="0" y="0" width="1" height="1" fill="#ffffff"/><rect x="7" y="0" width="1" height="1" fill="#ffffff"/><rect x="0" y="7" width="1" height="1" fill="#ffffff"/><rect x="7" y="7" width="1" height="1" fill="#ffffff"/><rect x="1" y="1" width="1" height="1" fill="#ffffff"/><rect x="6" y="6" width="1" height="1" fill="#ffffff"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

@@ -1,18 +0,0 @@
import { itemVisual } from '../theme/assets';
import styles from './BlackoutTimer.module.css';
/** Seconds left on a player's blackout, in the corner of their board. */
export function BlackoutTimer({ remaining, small = false }: { remaining: number; small?: boolean }) {
if (remaining <= 0) return null;
const visual = itemVisual('blind');
return (
<div
className={[styles.timer, small ? styles.small : '', remaining < 1 ? styles.ending : ''].join(' ')}
style={{ color: visual.color, borderColor: visual.color }}
title={visual.name}
>
<img className={styles.icon} src={visual.icon} alt="" />
{remaining.toFixed(1)}s
</div>
);
}
@@ -1,7 +1,16 @@
.timer {
.timers {
position: absolute;
top: 4px;
right: 4px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
pointer-events: none;
z-index: 2;
}
.timer {
display: flex;
align-items: center;
gap: 6px;
@@ -10,8 +19,6 @@
background: #000;
font-size: 12px;
line-height: 1;
pointer-events: none;
z-index: 2;
}
.icon {
@@ -23,6 +30,10 @@
.small {
top: 2px;
right: 2px;
gap: 2px;
}
.small .timer {
gap: 3px;
padding: 2px 3px;
border-width: 2px;
@@ -34,7 +45,7 @@
height: 8px;
}
/* Blink during the last second so the return of the board isn't a surprise. */
/* Blink during the last second so the end of the effect isn't a surprise. */
.ending {
animation: blink 0.25s steps(2) infinite;
}
@@ -0,0 +1,29 @@
import { itemVisual } from '../theme/assets';
import styles from './EffectTimers.module.css';
/** A timed item effect on a player: the item id and the seconds it has left. */
export type Effect = [itemId: string, remaining: number];
/** Seconds left on a player's timed item effects (Blackout, Transformation), in the corner of their board. */
export function EffectTimers({ effects, small = false }: { effects: Effect[]; small?: boolean }) {
const active = effects.filter(([, remaining]) => remaining > 0);
if (active.length === 0) return null;
return (
<div className={[styles.timers, small ? styles.small : ''].join(' ')}>
{active.map(([itemId, remaining]) => {
const visual = itemVisual(itemId);
return (
<div
key={itemId}
className={[styles.timer, remaining < 1 ? styles.ending : ''].join(' ')}
style={{ color: visual.color, borderColor: visual.color }}
title={visual.name}
>
<img className={styles.icon} src={visual.icon} alt="" />
{remaining.toFixed(1)}s
</div>
);
})}
</div>
);
}
@@ -1,7 +1,7 @@
import { memo } from 'react';
import type { PlayerState } from '../net/types';
import type { ItemFlash } from '../net/useGameConnection';
import { BlackoutTimer } from './BlackoutTimer';
import { EffectTimers } from './EffectTimers';
import { BoardCanvas } from './BoardCanvas';
import { ItemBar } from './ItemBar';
import { ItemFlashOverlay } from './ItemFlashOverlay';
@@ -30,7 +30,15 @@ export const MiniBoard = memo(function MiniBoard({ state, width, height, slot, s
</div>
<BoardCanvas state={state} width={width} height={height} cell={cell} highlighted={selected}>
<ItemFlashOverlay flashes={flashes} small />
<BlackoutTimer remaining={state.alive ? state.invisibleRemaining : 0} small />
{state.alive && (
<EffectTimers
effects={[
['blind', state.invisibleRemaining],
['transform', state.transformedRemaining],
]}
small
/>
)}
</BoardCanvas>
<div className={styles.items} style={{ width: width * cell + 6 }}>
<ItemBar items={state.inventory} size={Math.max(10, cell)} />
+3 -1
View File
@@ -52,7 +52,8 @@ export interface PlayerState {
name: string;
alive: boolean;
place: number | null;
/** One character per visible cell, row by row: '0' empty, '1'-'7' piece colors, '8' garbage. */
/** One character per visible cell, row by row: '0' empty, '1'-'7' piece colors, '8' garbage,
* '9'-'=' (values 9-13) odd shapes from Transformation. Decode with charCodeAt - 48. */
board: string;
items: ItemCell[];
active: ActivePieceState | null;
@@ -66,6 +67,7 @@ export interface PlayerState {
level: number;
pendingGarbage: number;
invisibleRemaining: number;
transformedRemaining: number;
}
export interface MatchEvent {
+7 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCurrentTrack, useSound } from '../audio/SoundContext';
import { useGameSounds } from '../audio/useGameSounds';
import { BlackoutTimer } from '../components/BlackoutTimer';
import { EffectTimers } from '../components/EffectTimers';
import { BoardCanvas } from '../components/BoardCanvas';
import { ItemBar } from '../components/ItemBar';
import { ItemFlashOverlay } from '../components/ItemFlashOverlay';
@@ -130,7 +130,12 @@ export function GamePage({ match, lobby, playerId, flashes, actions }: Props) {
highlighted={targetId === playerId}
>
<ItemFlashOverlay flashes={flashesFor(me.id)} />
<BlackoutTimer remaining={me.invisibleRemaining} />
<EffectTimers
effects={[
['blind', me.invisibleRemaining],
['transform', me.transformedRemaining],
]}
/>
{match.phase === 'countdown' && (
<div className={styles.overlay}>
<span className={styles.countdown}>{Math.ceil(match.countdown)}</span>
+2
View File
@@ -135,6 +135,8 @@ export function drawPreviewPiece(
if (!shape) return;
const w = Math.max(...shape.map(([x]) => x)) + 1;
const h = Math.max(...shape.map(([, y]) => y)) + 1;
// Three-row shapes (Transformation) are shrunk to fit the preview box.
cell = Math.min(cell, Math.floor(boxHeight / (h + 0.5)));
const ox = offsetX + (boxWidth - w * cell) / 2;
const oy = offsetY + (boxHeight - h * cell) / 2;
const color = dimmed ? '#555555' : (blockColors[piece.type] ?? '#fff');
@@ -7,4 +7,10 @@ export const pieceShapes: Record<number, [number, number][]> = {
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]],
// Odd shapes, only dealt during a Transformation.
9: [[0, 0]],
10: [[0, 0], [0, 1], [1, 1]],
11: [[0, 0], [2, 0], [0, 1], [1, 1], [2, 1]],
12: [[1, 0], [0, 1], [1, 1], [2, 1], [1, 2]],
13: [[0, 0], [0, 1], [1, 1], [1, 2], [2, 2]],
};
+6
View File
@@ -52,6 +52,12 @@ export const itemVisuals: Record<string, ItemVisual> = {
color: '#c452ff',
description: 'Hides placed blocks and the drop shadow for 4 seconds. Stacks.',
},
transform: {
icon: asset('assets/items/transform.svg'),
name: 'Transformation',
color: '#ff6fd8',
description: "Turns the target's next pieces into odd shapes for 8 seconds. Stacks.",
},
};
export const unknownItem: ItemVisual = {
+6 -1
View File
@@ -1,6 +1,6 @@
/**
* Block colors by cell value (see PlayerState.board). Swap these to restyle the blocks.
* Index 0 is the empty cell, 8 is garbage.
* Index 0 is the empty cell, 1-7 the standard pieces, 8 garbage, 9-13 the Transformation shapes.
*/
export const blockColors: Record<number, string> = {
1: '#3fe3f2',
@@ -11,6 +11,11 @@ export const blockColors: Record<number, string> = {
6: '#ff4256',
7: '#c452ff',
8: '#8c8c8c',
9: '#ff6fd8',
10: '#2fd6a3',
11: '#b8ff3f',
12: '#f2f2f2',
13: '#ff7a3a',
};
export const boardColors = {