mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 12:28:54 +00:00
The chain shows each word's score breakdown as small chips beside the total. A "Bí từ" button next to the input claims a dead end on the player's own turn, armed the same way resign is; a false claim is answered inline near the input rather than in the general error banner. A rejection for a word not in the dictionary offers the one real word a diacritic typo differs from, filling and focusing the field on click, and a button to report the word as real, acknowledged with a confirmation once the server has heard it.
496 lines
13 KiB
Svelte
496 lines
13 KiB
Svelte
<script>
|
||
import ChainHistory from '$lib/components/ChainHistory.svelte';
|
||
import ConnectionBadge from '$lib/components/ConnectionBadge.svelte';
|
||
import CountdownRing from '$lib/components/CountdownRing.svelte';
|
||
import ScoreBoard from '$lib/components/ScoreBoard.svelte';
|
||
import WordInput from '$lib/components/WordInput.svelte';
|
||
import { fill, t } from '$lib/i18n/vi.js';
|
||
import { game } from '$lib/stores/game.svelte.js';
|
||
import { Status, connection, reconnectNow } from '$lib/ws/connection.svelte.js';
|
||
|
||
/**
|
||
* The board itself, shared by both modes. It renders the store and nothing
|
||
* else; what a finished game offers differs between bot and online play, so
|
||
* that arrives as a snippet rather than as a branch in here.
|
||
*
|
||
* `chatUnread` and `onchatopen` are online-only: a bot game has no chat, so
|
||
* the caller simply never passes them, and the pill they draw does not
|
||
* appear. Passing them is what puts a way to reach the conversation above
|
||
* the chain rather than under it, which on a phone mid-game used to make
|
||
* the unread count effectively unreachable.
|
||
* @type {{
|
||
* modeLabel?: string,
|
||
* onsubmit: (word: string) => boolean,
|
||
* onresign: () => void,
|
||
* onclaimdeadend: () => void,
|
||
* onreportword: (word: string) => void,
|
||
* gameOver: import('svelte').Snippet,
|
||
* banner?: import('svelte').Snippet,
|
||
* chatUnread?: number,
|
||
* onchatopen?: () => void
|
||
* }}
|
||
*/
|
||
let {
|
||
modeLabel = '',
|
||
onsubmit,
|
||
onresign,
|
||
onclaimdeadend,
|
||
onreportword,
|
||
gameOver,
|
||
banner,
|
||
chatUnread = 0,
|
||
onchatopen
|
||
} = $props();
|
||
|
||
/** How long an armed resign or claim button waits before it goes back to being safe. */
|
||
const ARM_MS = 4000;
|
||
|
||
let arming = $state(false);
|
||
/** @type {ReturnType<typeof setTimeout>} */
|
||
let armTimer;
|
||
|
||
let claimArming = $state(false);
|
||
/** @type {ReturnType<typeof setTimeout>} */
|
||
let claimArmTimer;
|
||
|
||
// Whose turn it is, said by name. With four people at the table "the
|
||
// opponent is thinking" stops naming anybody.
|
||
const turnLabel = $derived.by(() => {
|
||
if (game.state.myTurn) return t.yourTurn;
|
||
const name = game.nameOf(game.state.turnPlayerId);
|
||
return name ? fill(t.playerTurn, { name }) : t.opponentTurn;
|
||
});
|
||
|
||
const offline = $derived(connection.status !== Status.OPEN);
|
||
|
||
// Giving up is a move: it is what a player plays instead of a word, so it
|
||
// is offered on their turn and no other. Out of turn the way out of a game
|
||
// is to leave the room, which the lobby's own button does.
|
||
const canResign = $derived(game.state.myTurn && !offline);
|
||
|
||
// An armed button that loses the turn goes back to being safe: the second
|
||
// press would arrive at a button that is no longer the one the player was
|
||
// looking at.
|
||
$effect(() => {
|
||
if (canResign) return;
|
||
clearTimeout(armTimer);
|
||
arming = false;
|
||
});
|
||
|
||
// A claim is the same offer resign is: made only on the player's own turn,
|
||
// on the same reasoning canResign already states.
|
||
const canClaimDeadEnd = $derived(game.state.myTurn && !offline);
|
||
|
||
$effect(() => {
|
||
if (canClaimDeadEnd) return;
|
||
clearTimeout(claimArmTimer);
|
||
claimArming = false;
|
||
});
|
||
|
||
/**
|
||
* Two presses, in place of a native confirm().
|
||
*
|
||
* confirm() blocks the main thread, which stops the countdown's animation
|
||
* frame loop while the server's deadline keeps running: hesitating over the
|
||
* dialog can cost the turn it was protecting. This keeps the board on screen
|
||
* and the clock moving, and disarms itself so a stray tap does not lie in
|
||
* wait.
|
||
*/
|
||
function armOrResign() {
|
||
if (arming) {
|
||
clearTimeout(armTimer);
|
||
arming = false;
|
||
onresign();
|
||
return;
|
||
}
|
||
arming = true;
|
||
clearTimeout(armTimer);
|
||
armTimer = setTimeout(() => (arming = false), ARM_MS);
|
||
}
|
||
|
||
/** Same two-press shape as armOrResign, and for the same reason. */
|
||
function armOrClaim() {
|
||
if (claimArming) {
|
||
clearTimeout(claimArmTimer);
|
||
claimArming = false;
|
||
onclaimdeadend();
|
||
return;
|
||
}
|
||
claimArming = true;
|
||
clearTimeout(claimArmTimer);
|
||
claimArmTimer = setTimeout(() => (claimArming = false), ARM_MS);
|
||
}
|
||
|
||
$effect(() => () => clearTimeout(armTimer));
|
||
$effect(() => () => clearTimeout(claimArmTimer));
|
||
</script>
|
||
|
||
<section class="board" data-phase={game.state.phase}>
|
||
<div class="top">
|
||
<ConnectionBadge />
|
||
<div class="meta">
|
||
{#if modeLabel}<span class="mode">{modeLabel}</span>{/if}
|
||
<a class="rules-link" href="/rules">{t.rulesLink}</a>
|
||
{#if onchatopen}
|
||
<!-- Above the chain rather than below it, which is where this used
|
||
to live: the chain grows a row per turn, and a badge under it
|
||
was two screens down by the time a game was worth talking
|
||
about. Scrolling the panel into view rather than opening it in
|
||
place, since a folded panel scrolled here still shows its own
|
||
badge and its own way to unfold. -->
|
||
<button
|
||
type="button"
|
||
class="chat-pill"
|
||
onclick={onchatopen}
|
||
data-testid="chat-pill"
|
||
>
|
||
{t.chatTitle}
|
||
{#if chatUnread > 0}
|
||
<span class="pill-badge">{fill(t.chatUnread, { n: chatUnread })}</span>
|
||
{/if}
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Not once the game is over: the result panel below carries the same
|
||
names and scores as a ranked table, and two of them is one screenful
|
||
of duplication between the result and the lobby's own buttons. -->
|
||
{#if game.state.phase !== 'over'}
|
||
<ScoreBoard />
|
||
{/if}
|
||
|
||
{#if banner}{@render banner()}{/if}
|
||
|
||
<!--
|
||
Not a live region: the connection badge above already announces the same
|
||
change, and saying it twice is worse than saying it once. This is the
|
||
visible half — and the only place the player can do anything about it,
|
||
since the backoff otherwise runs up to eight seconds while their turn
|
||
does not wait.
|
||
-->
|
||
{#if offline && game.state.phase === 'playing'}
|
||
<p class="offline">
|
||
{t.reconnecting}
|
||
<button type="button" onclick={() => reconnectNow()}>{t.retry}</button>
|
||
</p>
|
||
{/if}
|
||
|
||
{#if game.state.error}
|
||
<p class="error" role="alert">
|
||
{game.state.error}
|
||
<button
|
||
type="button"
|
||
class="icon-button"
|
||
onclick={() => game.clearError()}
|
||
aria-label={t.dismiss}>×</button
|
||
>
|
||
</p>
|
||
{/if}
|
||
|
||
{#if game.state.phase === 'over'}
|
||
{@render gameOver()}
|
||
{:else}
|
||
<div class="turn">
|
||
<CountdownRing />
|
||
<div class="prompt">
|
||
<!--
|
||
The turn changing is the one state change the whole game hangs
|
||
on, and it reached nobody who was not looking at this line: a
|
||
screen-reader player, or anybody reading the chat beside the
|
||
board, learned it from the clock forfeiting them.
|
||
-->
|
||
<p
|
||
class="who"
|
||
class:mine={game.state.myTurn}
|
||
role="status"
|
||
aria-live="polite"
|
||
aria-atomic="true"
|
||
data-testid="turn-indicator"
|
||
>
|
||
{turnLabel}
|
||
</p>
|
||
<p class="syllable">
|
||
<span class="label">{t.currentSyllable}</span>
|
||
<strong data-testid="current-syllable">{game.state.currentSyllable || '…'}</strong>
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- A player who has been knocked out watches the rest of it: the chain,
|
||
the clock and the chat all keep working, and only the one thing
|
||
they can no longer do goes away. -->
|
||
{#if game.iAmOut}
|
||
<p class="spectating">{t.spectating}</p>
|
||
{:else}
|
||
<WordInput {onsubmit} {onreportword} />
|
||
{#if game.state.myTurn}
|
||
<!-- Next to the input, not down by resign: a dead end is read off
|
||
the current syllable, which only means something on this
|
||
player's own turn — unlike resign, there is no "not yet" state
|
||
worth showing for it off turn. -->
|
||
<button
|
||
type="button"
|
||
class="claim-dead-end"
|
||
class:arming={claimArming}
|
||
disabled={!canClaimDeadEnd}
|
||
onclick={armOrClaim}
|
||
>
|
||
{claimArming ? t.claimDeadEndSure : t.claimDeadEnd}
|
||
</button>
|
||
{/if}
|
||
{#if game.state.claimError}
|
||
<p class="claim-error" role="alert">
|
||
{game.state.claimError}
|
||
<button
|
||
type="button"
|
||
class="icon-button"
|
||
onclick={() => game.clearClaimError()}
|
||
aria-label={t.dismiss}>×</button
|
||
>
|
||
</p>
|
||
{/if}
|
||
{/if}
|
||
{/if}
|
||
|
||
<!-- Above the chain, not below it: the chain is the one part of the board
|
||
that grows, and a button under it walks off the bottom of the screen
|
||
exactly as the game gets long enough to want to give up on. -->
|
||
{#if game.state.phase === 'playing' && !game.iAmOut}
|
||
<button
|
||
type="button"
|
||
class="resign"
|
||
class:arming
|
||
disabled={!canResign}
|
||
onclick={armOrResign}
|
||
>
|
||
{arming ? t.resignSure : t.resign}
|
||
</button>
|
||
{/if}
|
||
|
||
<ChainHistory />
|
||
</section>
|
||
|
||
<style>
|
||
.board {
|
||
display: flex;
|
||
flex-direction: column;
|
||
flex: 1;
|
||
gap: var(--space-3);
|
||
min-height: 0;
|
||
padding-bottom: var(--space-2);
|
||
}
|
||
|
||
.top {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-2);
|
||
}
|
||
|
||
.meta {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: var(--space-3);
|
||
}
|
||
|
||
.mode {
|
||
color: var(--text-muted);
|
||
font-size: var(--text-4);
|
||
}
|
||
|
||
.chat-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-height: 32px;
|
||
padding: 4px var(--space-3);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-pill);
|
||
background: var(--surface-alt);
|
||
font-size: var(--text-3);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.pill-badge {
|
||
padding: 1px var(--space-2);
|
||
border-radius: var(--radius-pill);
|
||
background: var(--accent);
|
||
color: var(--accent-text);
|
||
font-size: var(--text-1);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.turn {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--space-4);
|
||
}
|
||
|
||
.prompt {
|
||
min-width: 0;
|
||
}
|
||
|
||
.who {
|
||
margin: 0 0 var(--space-1);
|
||
color: var(--text-muted);
|
||
font-size: var(--text-4);
|
||
}
|
||
|
||
/* The player's own turn, said loudly enough to catch the eye that is in the
|
||
chat column beside the board. */
|
||
.who.mine {
|
||
color: var(--text);
|
||
font-size: var(--text-6);
|
||
font-weight: 700;
|
||
}
|
||
|
||
.syllable {
|
||
display: flex;
|
||
flex-direction: column;
|
||
margin: 0;
|
||
}
|
||
|
||
.syllable .label {
|
||
margin-bottom: 2px;
|
||
color: var(--text-muted);
|
||
font-size: var(--text-2);
|
||
}
|
||
|
||
/* The one glyph read every single turn, so it gets the headroom: a stacked
|
||
Vietnamese tone mark on ệ or ộ rides into the label above it at 1.2. */
|
||
.syllable strong {
|
||
font-size: 1.6rem;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.error,
|
||
.offline {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-2);
|
||
margin: 0;
|
||
padding: 10px var(--space-3);
|
||
border-radius: var(--radius-sm);
|
||
font-size: var(--text-5);
|
||
}
|
||
|
||
.error {
|
||
background: var(--danger-soft);
|
||
color: var(--danger);
|
||
}
|
||
|
||
.offline {
|
||
background: var(--surface-alt);
|
||
color: var(--warn);
|
||
}
|
||
|
||
.offline button {
|
||
flex: none;
|
||
min-height: 44px;
|
||
padding: var(--space-2) var(--space-4);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-sm);
|
||
margin: -8px 0;
|
||
background: var(--surface);
|
||
color: var(--text);
|
||
font-size: var(--text-4);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.spectating {
|
||
margin: 0;
|
||
padding: var(--space-3);
|
||
border: 1px dashed var(--border);
|
||
border-radius: var(--radius-sm);
|
||
color: var(--text-muted);
|
||
text-align: center;
|
||
}
|
||
|
||
/* Right of the board and away from the input: giving up is the one thing
|
||
here nobody should hit by accident while typing. Danger coloured because
|
||
it ends the game, subordinate because it is not the way to play it. */
|
||
.resign {
|
||
align-self: flex-end;
|
||
/* Below the 44px the rest of the controls keep, deliberately: this is
|
||
the one button here nobody is trying to hit, it takes two presses to
|
||
do anything, and at full size it read as an offer rather than as the
|
||
way out. Still its own outlined block in danger colour, so it is
|
||
plainly findable rather than hidden. */
|
||
min-height: 32px;
|
||
padding: var(--space-1) var(--space-3);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--danger);
|
||
font-size: var(--text-3);
|
||
transition: background-color 150ms ease-out;
|
||
}
|
||
|
||
.resign:hover:enabled {
|
||
background: var(--danger-soft);
|
||
}
|
||
|
||
/* Off turn: still there, so the way out of the game does not appear and
|
||
disappear under the player's thumb every handover, but plainly not the
|
||
thing to press yet. */
|
||
.resign:disabled {
|
||
border-color: var(--border);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
/* Armed, and saying so: the second press is the one that ends the game. */
|
||
.resign.arming {
|
||
border-color: var(--danger);
|
||
background: var(--danger-soft);
|
||
font-weight: 600;
|
||
}
|
||
|
||
/* Beside the input rather than down with resign: a dead-end claim is about
|
||
the syllable on screen right now, so it reads as part of answering it
|
||
rather than as a way out of the game. Secondary weight either way — it
|
||
is not the way to play a turn, just a shortcut past an empty one. */
|
||
.claim-dead-end {
|
||
align-self: flex-start;
|
||
min-height: 32px;
|
||
padding: var(--space-1) var(--space-3);
|
||
border: 1px solid var(--border-strong);
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--text);
|
||
font-size: var(--text-3);
|
||
transition: background-color 150ms ease-out;
|
||
}
|
||
|
||
.claim-dead-end:hover:enabled {
|
||
background: var(--surface-alt);
|
||
}
|
||
|
||
.claim-dead-end:disabled {
|
||
border-color: var(--border);
|
||
color: var(--text-muted);
|
||
}
|
||
|
||
.claim-dead-end.arming {
|
||
border-color: var(--accent);
|
||
background: var(--accent-soft);
|
||
font-weight: 600;
|
||
}
|
||
|
||
.claim-error {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: var(--space-2);
|
||
margin: 0;
|
||
padding: 10px var(--space-3);
|
||
border-radius: var(--radius-sm);
|
||
background: var(--danger-soft);
|
||
color: var(--danger);
|
||
font-size: var(--text-5);
|
||
}
|
||
</style>
|