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

<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>

Powered by TurnKey Linux.