mirror of
https://github.com/tiennm99/noitu.git
synced 2026-10-13 12:14:59 +00:00
feat(web): teach the chain rule on landing and remember the last difficulty
A newcomer used to reach the first running clock never having been told what nối từ requires; the landing screen now states the rule with a worked example before the nickname field, and the chain's own opening row gets a caption explaining why it starts the way it does. The difficulty ladder's record line falls back to a truthful one-line description of what the bot actually does at that rung instead of "chưa có", and the rung a player picks now survives across visits in the settings store, so a bot game reached from elsewhere (the quick-match nudge, next commit) can resume it.
This commit is contained in:
1 parent
d94c2a105c
commit
81d99bb0cf
4 files changed
+101
-30
No files matched your search
@@ -99,24 +99,29 @@
|
||||
{/if}
|
||||
</span>
|
||||
</button>
|
||||
{#if entry.byMe && entry.typed && entry.typed !== entry.word}
|
||||
{#if entry.opening}
|
||||
<!-- A newcomer sees one grey word with nobody credited for it and
|
||||
no reason given why it starts the chain. -->
|
||||
<span class="corrected">{t.openingCaption}</span>
|
||||
{:else if entry.byMe && entry.typed && entry.typed !== entry.word}
|
||||
<!-- The server accepted a different spelling from the one typed.
|
||||
Saying so beats silently rewriting the player's word. -->
|
||||
<span class="corrected">
|
||||
{fill(t.correctedFrom, { typed: entry.typed, word: entry.word })}
|
||||
</span>
|
||||
{/if}
|
||||
{#if entry.parts.length}
|
||||
{#if entry.parts.length && (index === 0 || open)}
|
||||
<!-- Why the word scored what it did, not only that it did: the
|
||||
client has no wordlist to re-derive this from, so the terms
|
||||
travel with the total. -->
|
||||
<p class="parts">
|
||||
{entry.parts
|
||||
.map((/** @type {import('$lib/stores/game.svelte.js').PointPart} */ p) =>
|
||||
`+${p.value} ${pointKindLabels[p.kind] ?? ''}`
|
||||
)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
travel with the total. Shown only on the newest row and on
|
||||
one whose meaning panel is open — every row, in a 9rem list,
|
||||
used to turn three rows of history into three rows of
|
||||
arithmetic. -->
|
||||
<ul class="parts" aria-label={t.pointsBreakdown}>
|
||||
{#each entry.parts as p, partIndex (partIndex)}
|
||||
<li>+{p.value} <span>{pointKindLabels[p.kind] ?? ''}</span></li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if open}
|
||||
<!-- Plain text from the server, rendered as text: the builder
|
||||
@@ -155,7 +160,7 @@
|
||||
h2 {
|
||||
margin: 0 0 var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-4);
|
||||
font-size: var(--text-2);
|
||||
font-weight: 600;
|
||||
/* Uppercase Vietnamese stacks a tone mark above a capital, which the
|
||||
inherited 1.5 only just clears. */
|
||||
@@ -174,7 +179,7 @@
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
gap: var(--space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* overflow-y sets a flex item's automatic minimum size to 0, and the
|
||||
@@ -266,7 +271,7 @@
|
||||
}
|
||||
|
||||
.word {
|
||||
font-size: var(--text-6);
|
||||
font-size: var(--text-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -275,7 +280,7 @@
|
||||
padding-left: calc(12px + 1.4em);
|
||||
padding-right: 12px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-4);
|
||||
font-size: var(--text-2);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -292,12 +297,12 @@
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
font-size: var(--text-3);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
.by {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-3);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -316,14 +321,24 @@
|
||||
.corrected {
|
||||
padding: 0 12px 8px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-3);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
.parts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0 12px 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.parts li {
|
||||
padding: 0 var(--space-2);
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--surface-alt);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-2);
|
||||
font-size: var(--text-1);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { difficultyLabels, difficultyOrder, t } from '$lib/i18n/vi.js';
|
||||
import { difficultyHints, difficultyLabels, difficultyOrder, t } from '$lib/i18n/vi.js';
|
||||
import { settings } from '$lib/stores/settings.svelte.js';
|
||||
|
||||
/** @type {{ value: number, onselect?: (difficulty: number) => void }} */
|
||||
@@ -28,7 +28,12 @@
|
||||
}}
|
||||
/>
|
||||
<span class="name">{difficultyLabels[difficulty]}</span>
|
||||
<span class="best">{t.bestScore}: {best > 0 ? best : t.noBestScore}</span>
|
||||
<!-- What the rung does beats "chưa có" once there is no record to
|
||||
show instead: a first-time player has nothing true to read in a
|
||||
record line, but what the bot does at this rung always is. -->
|
||||
<span class="best">
|
||||
{best > 0 ? `${t.bestScore}: ${best}` : difficultyHints[difficulty]}
|
||||
</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -42,9 +47,9 @@
|
||||
}
|
||||
|
||||
legend {
|
||||
padding: 0 0 6px;
|
||||
padding: 0 0 var(--space-2);
|
||||
font-weight: 600;
|
||||
font-size: var(--text-5);
|
||||
font-size: var(--text-2);
|
||||
}
|
||||
|
||||
/* auto-fit rather than three fixed columns: at 360px each of three got
|
||||
@@ -87,6 +92,6 @@
|
||||
|
||||
.best {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-2);
|
||||
font-size: var(--text-1);
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@
|
||||
const NICKNAME_KEY = 'noitu.nickname';
|
||||
const THEME_KEY = 'noitu.theme';
|
||||
const BEST_KEY = 'noitu.bestScores';
|
||||
const DIFFICULTY_KEY = 'noitu.lastDifficulty';
|
||||
|
||||
/** Mirrors the server's own cap so the input cannot promise a name it will lose. */
|
||||
export const MAX_NICKNAME_LENGTH = 20;
|
||||
@@ -82,6 +83,20 @@ function normalizeTheme(value) {
|
||||
return value === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
/**
|
||||
* The bot difficulty last picked, from the landing screen or the quick-match
|
||||
* queue's own nudge. `null` when nothing has been picked yet in this browser
|
||||
* — a caller falls back to its own default rather than this store guessing
|
||||
* one on its behalf.
|
||||
* @returns {number | null}
|
||||
*/
|
||||
function initialDifficulty() {
|
||||
const raw = read(DIFFICULTY_KEY, '');
|
||||
if (raw === '') return null;
|
||||
const value = Number(raw);
|
||||
return Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
/** Reads the theme the inline script in app.html already applied, if any. */
|
||||
function initialTheme() {
|
||||
const saved = read(THEME_KEY, '');
|
||||
@@ -99,7 +114,9 @@ export function createSettingsStore() {
|
||||
/** @type {'light' | 'dark'} */
|
||||
theme: initialTheme(),
|
||||
/** @type {Record<string, number>} */
|
||||
bestScores: readBestScores()
|
||||
bestScores: readBestScores(),
|
||||
/** @type {number | null} */
|
||||
lastDifficulty: initialDifficulty()
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -128,6 +145,17 @@ export function createSettingsStore() {
|
||||
this.setTheme(state.theme === 'dark' ? 'light' : 'dark');
|
||||
},
|
||||
|
||||
/**
|
||||
* Remembers the ladder rung the player last picked, so a quick-match
|
||||
* wait offering the bot as a nudge sends them to the rung they already
|
||||
* chose rather than back to the ladder's default.
|
||||
* @param {number} difficulty - a Difficulty enum value
|
||||
*/
|
||||
setLastDifficulty(difficulty) {
|
||||
state.lastDifficulty = difficulty;
|
||||
write(DIFFICULTY_KEY, String(difficulty));
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {number|string} difficulty - a Difficulty enum value
|
||||
* @returns {number}
|
||||
|
||||
@@ -3,9 +3,17 @@
|
||||
import DifficultyPicker from '$lib/components/DifficultyPicker.svelte';
|
||||
import NicknameInput from '$lib/components/NicknameInput.svelte';
|
||||
import { Difficulty } from '$lib/proto/noitu/v1/game_pb.js';
|
||||
import { t } from '$lib/i18n/vi.js';
|
||||
import { fill, t } from '$lib/i18n/vi.js';
|
||||
import { settings } from '$lib/stores/settings.svelte.js';
|
||||
|
||||
let difficulty = $state(Difficulty.MEDIUM);
|
||||
// Resumes the rung last played rather than always opening on Medium, so
|
||||
// choosing a difficulty is not a step repeated every visit.
|
||||
let difficulty = $state(settings.state.lastDifficulty ?? Difficulty.MEDIUM);
|
||||
|
||||
/** @param {number} value */
|
||||
function selectDifficulty(value) {
|
||||
settings.setLastDifficulty(value);
|
||||
}
|
||||
|
||||
function playBot() {
|
||||
goto(`/play?difficulty=${difficulty}`);
|
||||
@@ -21,8 +29,14 @@
|
||||
screen reader has something to land on. -->
|
||||
<h1 class="tagline">{t.tagline}</h1>
|
||||
|
||||
<!-- The rule itself, before the nickname or the ladder: a newcomer's first
|
||||
read of it used to be a rejection message under a running clock. -->
|
||||
<p class="how">
|
||||
{fill(t.howToPlay, { example: t.howToPlayExample })}
|
||||
</p>
|
||||
|
||||
<NicknameInput />
|
||||
<DifficultyPicker bind:value={difficulty} />
|
||||
<DifficultyPicker bind:value={difficulty} onselect={selectDifficulty} />
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="primary" onclick={playBot}>{t.playBot}</button>
|
||||
@@ -45,19 +59,19 @@
|
||||
.tagline {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-6);
|
||||
font-size: var(--text-3);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
min-height: 44px;
|
||||
padding: 14px;
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
@@ -71,6 +85,15 @@
|
||||
border-color: transparent;
|
||||
background: var(--accent);
|
||||
color: var(--accent-text);
|
||||
transition: background-color 150ms ease-out;
|
||||
}
|
||||
|
||||
.actions .primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.actions .primary:active {
|
||||
background: var(--accent-pressed);
|
||||
}
|
||||
|
||||
/* Its own line under the two big taps, not one more of them: this is a
|
||||
|
||||
Reference in new issue
Block a user