mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-11 03:13:45 +00:00
At 360px the header used to open on two rows of chrome: the connection badge said "Đã kết nối" in full while it was true, "Luật chơi" was a full-width text link, and the room code sat crowded in between. The badge now shows only its dot once open, the rules link is a 44px icon with an aria-label, and the room code moves under the turn prompt it used to sit beside. "Bí từ" used to mount and unmount every handover, shifting the input under a player's thumb each turn; it now sits in one persistent row with "Đầu hàng", both built on ArmedButton and disabled off-turn rather than unmounted. A resign or claim raced by the turn moving on now answers beside the button that sent it instead of the top banner, matching how a false dead-end claim was already handled. Knockout stops saying "you're out" twice — once in a banner above the scoreboard, once in the spectating box below it — and the elimination suggestions a spectator used to wait for the game-over screen to see now show up the moment they're eliminated, keyed to the syllable they were actually stuck on rather than whatever the game has moved on to since.
88 lines
2.9 KiB
Svelte
88 lines
2.9 KiB
Svelte
<script>
|
|
import { untrack } from 'svelte';
|
|
import { fill, t } from '$lib/i18n/vi.js';
|
|
import { GameEndReason } from '$lib/proto/noitu/v1/game_pb.js';
|
|
import { game } from '$lib/stores/game.svelte.js';
|
|
|
|
/**
|
|
* What is happening to the other people in the room: who has dropped and
|
|
* how much longer their seat is held, and who has been knocked out.
|
|
*
|
|
* The countdown is local because the server sends a duration, not a
|
|
* deadline: it is a rough "how much longer" for the players watching, and
|
|
* the server still decides when a seat is actually forfeit.
|
|
* @type {Record<string, number>}
|
|
*/
|
|
let endsAt = $state({});
|
|
let now = $state(Date.now());
|
|
|
|
const away = $derived(game.awayPlayers);
|
|
|
|
// Knocked out and gone are different news. A player who left the room is
|
|
// not somebody the others are waiting on, and not somebody sitting there
|
|
// watching either — their seat is free.
|
|
const lastOutLabel = $derived.by(() => {
|
|
const out = game.state.lastOut;
|
|
if (!out) return '';
|
|
const copy = out.reason === GameEndReason.OPPONENT_LEFT ? t.playerLeft : t.playerOut;
|
|
return fill(copy, { name: out.name || t.someone });
|
|
});
|
|
|
|
// One deadline per player, opened when they drop and forgotten when they
|
|
// come back. Deliberately not keyed off the room state as a whole: it is
|
|
// broadcast on every change a player can see, and rebuilding the deadlines
|
|
// from it would restart every countdown each time somebody said they were
|
|
// ready.
|
|
$effect(() => {
|
|
const present = away.map((p) => p.playerId);
|
|
untrack(() => {
|
|
/** @type {Record<string, number>} */
|
|
const next = {};
|
|
for (const id of present) next[id] = endsAt[id] ?? Date.now() + game.state.graceMs;
|
|
|
|
const before = Object.keys(endsAt);
|
|
if (before.length !== present.length || before.some((id) => !(id in next))) {
|
|
endsAt = next;
|
|
}
|
|
});
|
|
});
|
|
|
|
$effect(() => {
|
|
if (away.length === 0) return;
|
|
const tick = setInterval(() => (now = Date.now()), 250);
|
|
return () => clearInterval(tick);
|
|
});
|
|
|
|
/** @param {string} playerId */
|
|
function secondsLeft(playerId) {
|
|
return Math.max(0, Math.ceil(((endsAt[playerId] ?? 0) - now) / 1000));
|
|
}
|
|
</script>
|
|
|
|
{#if game.state.lastOut && !game.state.lastOut.isMe && game.state.phase === 'playing'}
|
|
<p class="banner" role="status" data-testid="player-out">{lastOutLabel}</p>
|
|
{/if}
|
|
|
|
{#each away as player (player.playerId)}
|
|
{@const seconds = secondsLeft(player.playerId)}
|
|
<p class="banner" role="status" data-testid={`away-${player.playerId}`}>
|
|
{#if seconds > 0}
|
|
{fill(t.playerDisconnectedIn, { name: player.name || t.someone, n: seconds })}
|
|
{:else}
|
|
{fill(t.playerDisconnected, { name: player.name || t.someone })}
|
|
{/if}
|
|
</p>
|
|
{/each}
|
|
|
|
<style>
|
|
.banner {
|
|
margin: 0;
|
|
padding: var(--space-3) var(--space-3);
|
|
border-radius: var(--radius-sm);
|
|
background: var(--surface-alt);
|
|
color: var(--warn);
|
|
font-size: var(--text-2);
|
|
text-align: center;
|
|
}
|
|
</style>
|