You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
327 lines
9.8 KiB
327 lines
9.8 KiB
<script lang="ts">
|
|
import { onDestroy, untrack } from 'svelte';
|
|
import { flip } from 'svelte/animate';
|
|
import type { Room, Action } from '../types';
|
|
import Dice from './Dice.svelte';
|
|
import { diceFeedback } from '../dice-feedback';
|
|
import { playSound } from '../sound.svelte';
|
|
import { OcaPlayback } from '../oca-playback';
|
|
import {
|
|
colorForPlayer,
|
|
playerColorClass,
|
|
} from '../../../shared/player-colors.mjs';
|
|
import AvatarImage from './AvatarImage.svelte';
|
|
import CellInfo from './CellInfo.svelte';
|
|
import { ocaBoard, ocaCell } from '../../../shared/oca-board.mjs';
|
|
let {
|
|
room,
|
|
disabled = false,
|
|
onaction,
|
|
}: {
|
|
room: Room;
|
|
disabled?: boolean;
|
|
onaction: (action: Action) => void;
|
|
} = $props();
|
|
const lastRoll = $derived(
|
|
room.events?.filter((e) => e.type === 'roll').at(-1)?.sequence ?? 0,
|
|
);
|
|
const feedback = $derived(diceFeedback(room));
|
|
let displayed = $state<number[]>([]),
|
|
busy = $state(false),
|
|
hop = $state(0),
|
|
hopPlayer = $state<number | null>(null),
|
|
stepDuration = $state(160);
|
|
let arrival = $state<string | null>(null);
|
|
let arrivalPlayer = $state<number | null>(null);
|
|
const playback = new OcaPlayback({
|
|
frame: (positions, player, duration) => {
|
|
displayed = positions;
|
|
hopPlayer = player;
|
|
stepDuration = duration;
|
|
hop++;
|
|
},
|
|
busy: (value) => (busy = value),
|
|
sound: (definition, event) => void playSound(definition, event),
|
|
arrival: (player, effect) => {
|
|
arrivalPlayer = player;
|
|
arrival = effect;
|
|
hopPlayer = null;
|
|
},
|
|
reducedMotion: () =>
|
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
|
});
|
|
const artwork = $derived(room.definition?.board ?? ocaBoard);
|
|
const viewport = $derived(artwork.viewport ?? ocaBoard.viewport);
|
|
let hovered = $state<number | null>(null),
|
|
selected = $state<number | null>(null);
|
|
const inspected = $derived(selected ?? hovered);
|
|
function position(n: number) {
|
|
return artwork.centers[n];
|
|
}
|
|
function tile(n: number) {
|
|
const [x, y] = position(n),
|
|
width = n === 63 ? 248 : n < 3 ? 108 : 120,
|
|
height = n === 63 ? 264 : 126;
|
|
return { x: x - width / 2, y: y - height / 2, width, height };
|
|
}
|
|
function dismiss() {
|
|
selected = null;
|
|
hovered = null;
|
|
}
|
|
$effect(() => {
|
|
room.id;
|
|
lastRoll;
|
|
untrack(() => playback.receive(room));
|
|
});
|
|
onDestroy(() => playback.dispose());
|
|
</script>
|
|
|
|
<div class="table-game oca-table">
|
|
<div class="board-surface illustrated-board">
|
|
<svg
|
|
class="game-map oca-map"
|
|
viewBox={`${viewport.x} ${viewport.y} ${viewport.width} ${viewport.height}`}
|
|
role="group"
|
|
aria-label="Tablero de la oca: recorrido en espiral hasta el jardín de la casilla 63"
|
|
>
|
|
<defs
|
|
><clipPath id="oca-cells-clip"
|
|
>{#each artwork.centers as _, n}{@const rect = tile(n)}<rect
|
|
x={rect.x}
|
|
y={rect.y}
|
|
width={rect.width}
|
|
height={rect.height}
|
|
rx="8"
|
|
/>{/each}</clipPath
|
|
></defs
|
|
>
|
|
<image
|
|
href={artwork.image}
|
|
width={artwork.width}
|
|
height={artwork.height}
|
|
clip-path="url(#oca-cells-clip)"
|
|
/>
|
|
{#each artwork.centers as _, n}{@const rect = tile(n)}{@const info =
|
|
ocaCell(n)}
|
|
{#if info.kind !== 'normal'}
|
|
<g
|
|
class="cell-hotspot"
|
|
class:active={inspected === n}
|
|
role="button"
|
|
tabindex="0"
|
|
aria-label={`${info.title}: ${info.description}`}
|
|
aria-expanded={selected === n}
|
|
onpointerenter={(e) => {
|
|
if (e.pointerType === 'mouse') hovered = n;
|
|
}}
|
|
onpointerleave={() => (hovered = null)}
|
|
onfocus={() => (hovered = n)}
|
|
onblur={() => (hovered = null)}
|
|
onclick={() => (selected = selected === n ? null : n)}
|
|
onkeydown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
selected = selected === n ? null : n;
|
|
} else if (e.key === 'Escape') dismiss();
|
|
}}
|
|
>
|
|
<rect
|
|
class="cell-hit"
|
|
x={rect.x}
|
|
y={rect.y}
|
|
width={rect.width}
|
|
height={rect.height}
|
|
rx="10"
|
|
/>
|
|
</g>
|
|
{/if}
|
|
{/each}
|
|
{#if inspected !== null}{@const rect = tile(inspected)}
|
|
<g
|
|
class="cell-enlargement"
|
|
transform={`translate(${rect.x + rect.width / 2} ${rect.y + rect.height / 2}) scale(1.12) translate(${-rect.x - rect.width / 2} ${-rect.y - rect.height / 2})`}
|
|
>
|
|
<svg
|
|
x={rect.x}
|
|
y={rect.y}
|
|
width={rect.width}
|
|
height={rect.height}
|
|
viewBox={`${rect.x} ${rect.y} ${rect.width} ${rect.height}`}
|
|
><image
|
|
href={artwork.image}
|
|
width={artwork.width}
|
|
height={artwork.height}
|
|
/></svg
|
|
>
|
|
<rect
|
|
x={rect.x}
|
|
y={rect.y}
|
|
width={rect.width}
|
|
height={rect.height}
|
|
rx="10"
|
|
fill="none"
|
|
/>
|
|
</g>
|
|
{/if}
|
|
{#each displayed as cell, seat (seat)}{@const point = position(cell)}
|
|
<g
|
|
animate:flip={{ duration: stepDuration, easing: (t) => t }}
|
|
data-player={seat}
|
|
data-cell={cell}
|
|
style:display={room.players[seat]?.withdrawnAt ? 'none' : undefined}
|
|
>
|
|
{#key seat === hopPlayer ? hop : 0}
|
|
<g
|
|
class="token-body"
|
|
class:hopping={seat === hopPlayer && stepDuration > 0}
|
|
class:bonus={seat === arrivalPlayer && arrival === 'bonus'}
|
|
class:penalty={seat === arrivalPlayer && arrival === 'penalty'}
|
|
class:goal={seat === arrivalPlayer && arrival === 'goal'}
|
|
style={`--hop-duration:${stepDuration}ms;--arrival-duration:${room.definition?.animations?.[arrival ?? 'move']?.duration ?? 450}ms`}
|
|
>
|
|
<circle
|
|
cx={point[0] - 24 + (seat % 2) * 48}
|
|
cy={point[1] + 10 + Math.floor(seat / 2) * 48}
|
|
r="23"
|
|
fill={colorForPlayer(room.players[seat]).hex}
|
|
stroke="#fffaf0"
|
|
stroke-width="4"
|
|
/>
|
|
<text
|
|
x={point[0] - 24 + (seat % 2) * 48}
|
|
y={point[1] + 18 + Math.floor(seat / 2) * 48}
|
|
text-anchor="middle"
|
|
font-size="23"
|
|
font-weight="700"
|
|
fill={['amarillo', 'azul', 'verde', 'naranja'].includes(
|
|
colorForPlayer(room.players[seat]).id,
|
|
)
|
|
? '#202c41'
|
|
: '#ffffff'}>{seat + 1}</text
|
|
>
|
|
</g>
|
|
{/key}
|
|
</g>
|
|
{/each}
|
|
</svg>
|
|
{#if inspected !== null}<CellInfo
|
|
info={ocaCell(inspected)}
|
|
x={((position(inspected)[0] - viewport.x) / viewport.width) * 100}
|
|
y={((position(inspected)[1] - viewport.y) / viewport.height) * 100}
|
|
pinned={selected !== null}
|
|
onclose={dismiss}
|
|
/>{/if}
|
|
</div>
|
|
<div class="game-controls oca-controls">
|
|
<Dice
|
|
value={room.state?.rolled}
|
|
rollId={lastRoll}
|
|
{feedback}
|
|
definition={room.definition}
|
|
disabled={disabled || busy}
|
|
canRoll={room.legalActions?.some((a) => a.type === 'roll')}
|
|
onroll={() => onaction({ type: 'roll' })}
|
|
onresultclose={(id) => playback.release(Number(id))}
|
|
/>
|
|
<p class="oca-instruction">
|
|
{#if room.state?.rolled}<span class="last-roll-caption"
|
|
>{feedback.title}</span
|
|
>{/if}{feedback.next}
|
|
</p>
|
|
<p class="oca-note">{room.state?.note}</p>
|
|
<div class="table-players">
|
|
{#each room.players as player}<div
|
|
class="table-player {playerColorClass(player)}"
|
|
>
|
|
<span class="mini-avatar"><AvatarImage person={player} /></span
|
|
><strong
|
|
>{player.nick}{player.seat === room.seat ? ' · tú' : ''}</strong
|
|
><small
|
|
>{#if player.withdrawnAt}Se ha retirado{:else}Casilla {room.state
|
|
?.positions?.[player.seat] ?? 0}{room.state?.skips?.[
|
|
player.seat
|
|
]
|
|
? ` · ${room.state.skips[player.seat]} descansos`
|
|
: ''}{/if}</small
|
|
>
|
|
</div>{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<style>
|
|
.illustrated-board {
|
|
position: relative;
|
|
}
|
|
.oca-map {
|
|
overflow: visible;
|
|
}
|
|
.cell-hit {
|
|
fill: transparent;
|
|
stroke: transparent;
|
|
stroke-width: 3;
|
|
}
|
|
.cell-hotspot {
|
|
cursor: help;
|
|
outline: none;
|
|
}
|
|
.cell-hotspot:focus-visible .cell-hit,
|
|
.cell-hotspot.active .cell-hit {
|
|
stroke: var(--accent);
|
|
}
|
|
.cell-enlargement {
|
|
pointer-events: none;
|
|
filter: drop-shadow(var(--shadow-board));
|
|
}
|
|
.cell-enlargement > rect {
|
|
stroke: var(--accent);
|
|
stroke-width: 4;
|
|
}
|
|
@media (hover: none), (prefers-reduced-motion: reduce) {
|
|
.cell-enlargement {
|
|
transform: none;
|
|
}
|
|
}
|
|
.token-body {
|
|
transform-box: fill-box;
|
|
transform-origin: center;
|
|
}
|
|
.hopping {
|
|
animation: cell-hop var(--hop-duration) ease-in-out;
|
|
}
|
|
@keyframes cell-hop {
|
|
50% {
|
|
transform: translateY(-30px) scale(1.08);
|
|
}
|
|
}
|
|
.bonus,
|
|
.goal {
|
|
animation: arrive-jump var(--arrival-duration) var(--ease-ui);
|
|
}
|
|
.penalty {
|
|
animation: arrive-shake var(--arrival-duration) var(--ease-ui);
|
|
}
|
|
@keyframes arrive-jump {
|
|
30% {
|
|
transform: translateY(-12px) scale(1.15);
|
|
}
|
|
65% {
|
|
transform: translateY(0) scale(0.9);
|
|
}
|
|
}
|
|
@keyframes arrive-shake {
|
|
25%,
|
|
75% {
|
|
transform: translateX(-7px);
|
|
}
|
|
50% {
|
|
transform: translateX(7px);
|
|
}
|
|
}
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.token-body {
|
|
animation: none;
|
|
}
|
|
}
|
|
</style>
|