added global item config
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import * as signalR from '@microsoft/signalr';
|
||||
import { getServerUrl } from '../config/runtimeConfig';
|
||||
import type { InputAction, JoinResult, Lobby, MatchState } from './types';
|
||||
import type { InputAction, ItemInfo, JoinResult, Lobby, MatchState } from './types';
|
||||
|
||||
export interface HubHandlers {
|
||||
onLobbyUpdated: (lobby: Lobby) => void;
|
||||
@@ -64,6 +64,11 @@ export class HubClient {
|
||||
return this.invoke<void>('StartGame');
|
||||
}
|
||||
|
||||
/** Items with their spawn share and duration from the server config. */
|
||||
getItems() {
|
||||
return this.invoke<ItemInfo[]>('GetItems');
|
||||
}
|
||||
|
||||
/** Fire-and-forget input; ordering is preserved by the connection. */
|
||||
input(action: InputAction) {
|
||||
if (this.connection.state === signalR.HubConnectionState.Connected) {
|
||||
|
||||
@@ -23,6 +23,16 @@ export interface Lobby {
|
||||
lastResults: MatchResult[] | null;
|
||||
}
|
||||
|
||||
/** An item of the server's current config (config/game.json). */
|
||||
export interface ItemInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Fraction (0..1) of spawned items that are this item; 0 = disabled. */
|
||||
share: number;
|
||||
/** Effect length for timed items, null for instant ones. */
|
||||
durationSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface JoinResult {
|
||||
playerId: string;
|
||||
lobby: Lobby;
|
||||
|
||||
@@ -79,12 +79,13 @@ export function useGameConnection() {
|
||||
}, []);
|
||||
|
||||
const startGame = useCallback(() => clientRef.current!.startGame(), []);
|
||||
const getItems = useCallback(() => clientRef.current!.getItems(), []);
|
||||
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],
|
||||
() => ({ createLobby, joinLobby, leaveLobby, startGame, getItems, sendInput, selectTarget }),
|
||||
[createLobby, joinLobby, leaveLobby, startGame, getItems, sendInput, selectTarget],
|
||||
);
|
||||
|
||||
return { status, playerId, lobby, match, flashes, actions };
|
||||
|
||||
@@ -127,6 +127,20 @@
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.itemText {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.itemShare {
|
||||
color: var(--color-muted);
|
||||
}
|
||||
|
||||
.itemDescription {
|
||||
color: var(--color-muted);
|
||||
font-size: 8px;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Logo } from '../components/Logo';
|
||||
import { MusicTracks } from '../components/MusicTracks';
|
||||
import { CornerControls } from '../components/CornerControls';
|
||||
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 { ItemInfo, Lobby } from '../net/types';
|
||||
import type { GameActions } from '../net/useGameConnection';
|
||||
import { itemVisuals } from '../theme/assets';
|
||||
import { itemDescription, itemVisual, itemVisuals } from '../theme/assets';
|
||||
import styles from './LobbyPage.module.css';
|
||||
|
||||
interface Props {
|
||||
@@ -20,6 +20,23 @@ export function LobbyPage({ lobby, playerId, actions }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const isHost = lobby.hostId === playerId;
|
||||
// Items as configured on the server; null until loaded (or if the call fails) = built-in list.
|
||||
const [items, setItems] = useState<ItemInfo[] | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
actions
|
||||
.getItems()
|
||||
.then((list) => !cancelled && setItems(list))
|
||||
.catch(() => {});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [actions]);
|
||||
|
||||
const shownItems = items
|
||||
? items.filter((i) => i.share > 0).map((i) => ({ id: i.id, share: i.share, duration: i.durationSeconds }))
|
||||
: Object.keys(itemVisuals).map((id) => ({ id, share: null, duration: null }));
|
||||
|
||||
const copyLink = async () => {
|
||||
const url = `${window.location.origin}${window.location.pathname}?code=${lobby.code}`;
|
||||
@@ -107,15 +124,21 @@ export function LobbyPage({ lobby, playerId, actions }: Props) {
|
||||
<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>
|
||||
))}
|
||||
{shownItems.map(({ id, share, duration }) => {
|
||||
const v = itemVisual(id);
|
||||
return (
|
||||
<li key={id}>
|
||||
<img src={v.icon} alt="" width={20} height={20} />
|
||||
<div className={styles.itemText}>
|
||||
<div className={styles.itemName}>
|
||||
<span style={{ color: v.color }}>{v.name}</span>
|
||||
{share !== null && <span className={styles.itemShare}>{formatShare(share)}</span>}
|
||||
</div>
|
||||
<div className={styles.itemDescription}>{itemDescription(v, duration)}</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Panel>
|
||||
|
||||
@@ -126,3 +149,8 @@ export function LobbyPage({ lobby, playerId, actions }: Props) {
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/** Share of spawned items as a percentage, e.g. "17%". */
|
||||
function formatShare(share: number): string {
|
||||
return share < 0.01 ? '<1%' : `${Math.round(share * 100)}%`;
|
||||
}
|
||||
|
||||
@@ -16,8 +16,10 @@ export interface ItemVisual {
|
||||
name: string;
|
||||
/** Accent color used for flashes and as icon fallback. */
|
||||
color: string;
|
||||
/** Short explanation for the lobby help panel. */
|
||||
/** Short explanation for the lobby help panel. "{duration}" is replaced with the configured seconds. */
|
||||
description: string;
|
||||
/** Built-in duration of timed items, shown when the server config can't be read. */
|
||||
defaultDurationSeconds?: number;
|
||||
}
|
||||
|
||||
/** Keyed by the server item id (IItem.Id). Add an entry here when adding an item on the server. */
|
||||
@@ -50,13 +52,15 @@ export const itemVisuals: Record<string, ItemVisual> = {
|
||||
icon: asset('assets/items/blind.svg'),
|
||||
name: 'Blackout',
|
||||
color: '#c452ff',
|
||||
description: 'Hides placed blocks and the drop shadow for 4 seconds. Stacks.',
|
||||
description: 'Hides placed blocks and the drop shadow for {duration} seconds. Stacks.',
|
||||
defaultDurationSeconds: 4,
|
||||
},
|
||||
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.",
|
||||
description: "Turns the target's next pieces into odd shapes for {duration} seconds. Stacks.",
|
||||
defaultDurationSeconds: 8,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -95,6 +99,12 @@ export function itemVisual(id: string): ItemVisual {
|
||||
return itemVisuals[id] ?? unknownItem;
|
||||
}
|
||||
|
||||
/** The lobby description of an item, with its duration filled in. */
|
||||
export function itemDescription(visual: ItemVisual, durationSeconds?: number | null): string {
|
||||
const seconds = durationSeconds ?? visual.defaultDurationSeconds;
|
||||
return seconds === undefined ? visual.description : visual.description.replace('{duration}', String(seconds));
|
||||
}
|
||||
|
||||
const imageCache = new Map<string, HTMLImageElement>();
|
||||
|
||||
/** Returns a (possibly still loading) image for canvas rendering. */
|
||||
|
||||
Reference in New Issue
Block a user