Compare commits
2
Commits
c55e5a2484
...
dd4714f499
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd4714f499 | ||
|
|
7a525d084a |
@@ -7,5 +7,6 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/game ./server
|
|||||||
|
|
||||||
FROM gcr.io/distroless/static
|
FROM gcr.io/distroless/static
|
||||||
COPY --from=build /out/game /game
|
COPY --from=build /out/game /game
|
||||||
|
ENV MUSIC_DIR=/music
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["/game"]
|
ENTRYPOINT ["/game"]
|
||||||
|
|||||||
@@ -1,2 +1,86 @@
|
|||||||
# Country-Game---The-Desk
|
# The Desk
|
||||||
|
|
||||||
|
A single-player browser game. You rule the fictional Republic of Valdoria for five in-game years from a wooden desk: a yes/no proposal lands in your tray every week, you set the budget and taxes every quarter, and the country answers. Debt, unrest and repression can end your term early. English and French.
|
||||||
|
|
||||||
|
The rules are in [web/data/rules/rules.en.md](web/data/rules/rules.en.md) and [rules.fr.md](web/data/rules/rules.fr.md). They are plain Markdown, and the game shows the same files under **Menu → Rules**.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
You need Docker with Compose.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build -d # build and start
|
||||||
|
# open http://localhost:8080
|
||||||
|
docker compose down # stop
|
||||||
|
```
|
||||||
|
|
||||||
|
The container listens on port 8080 (change the left side of `ports` in `docker-compose.yml` to use another host port). It restarts unless stopped and has a health check (`/healthz`, probed by the binary itself because the image has no shell).
|
||||||
|
|
||||||
|
Without Docker (Go 1.23+):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./server # PORT=3000 go run ./server to pick a port
|
||||||
|
```
|
||||||
|
|
||||||
|
There is nothing to configure: no database, and no environment variables except the optional `PORT` and `MUSIC_DIR`. Saves live in the player's browser (`localStorage`), with three manual slots and export/import as text.
|
||||||
|
|
||||||
|
## Music
|
||||||
|
|
||||||
|
The cassette player on the desk plays the `.mp3` files of [music/](music/) in random order (keys: previous, play, pause, stop, next). Drop files in that folder and reload the page: no rebuild, because Docker mounts the folder read-only and the server lists it live. File names become the titles (`03_Evening_Report.mp3` shows as "Evening Report"). `placeholder.mp3` is an empty file that is ignored. Without Docker the server reads `./music`, or the folder in `MUSIC_DIR`. Each sound effect and the music have their own slider under **Menu → Settings → Sound mixer**.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- **Server:** Go, standard library only. It serves the files under `web/` (embedded in the binary), the music folder and `/healthz`. No game logic runs on the server.
|
||||||
|
- **Game:** vanilla JavaScript ES modules and CSS. No framework, no bundler, no npm, no runtime dependency. Charts are hand-drawn SVG, sound effects are synthesised with the Web Audio API (no audio files), fonts are bundled (OFL).
|
||||||
|
- **Image:** multi-stage build into `gcr.io/distroless/static` (a few MB).
|
||||||
|
|
||||||
|
## Folders
|
||||||
|
|
||||||
|
```
|
||||||
|
server/ Go entry point (main.go: static files, music folder, health check) and the tests (*_test.go)
|
||||||
|
music/ your .mp3 files for the cassette player (mounted into the container)
|
||||||
|
web/
|
||||||
|
embed.go go:embed of everything below
|
||||||
|
index.html the page
|
||||||
|
css/ desk, papers, ledger, decision, menu, endgame, anim (motion), mobile (phone layout)
|
||||||
|
fonts/ img/ bundled fonts and small images
|
||||||
|
js/
|
||||||
|
main.js boot, lifecycle, the real-time loop (frames + a worker heartbeat for background play)
|
||||||
|
timer-worker.js heartbeat that keeps the game running in a hidden tab
|
||||||
|
engine/ the game rules: pure functions, no DOM, no network (state, economy, sectors,
|
||||||
|
indicators, cards, gameover, score, world, projection, validate...)
|
||||||
|
ui/ DOM panels: dashboard, decision (the tray), calendar (dial clock), ledger, newspaper,
|
||||||
|
telegram, endgame, menu, shelf (phone), walkman, audio, music, markdown
|
||||||
|
save/ save codec (CG1:<crc>:<base64>) with migrations, and storage with fallbacks
|
||||||
|
i18n/ interface strings, en.json and fr.json
|
||||||
|
data/
|
||||||
|
balance.json every tunable number
|
||||||
|
scenarios/ the country at the start (default.json, index.json)
|
||||||
|
cards/ the proposal pool, one file per category (900+ cards, EN and FR)
|
||||||
|
characters.json newspaper.json endgame.json rules/
|
||||||
|
dev/ headless simulation page (sim.html)
|
||||||
|
tools/ Node scripts: tests, balance, card and scenario helpers (below)
|
||||||
|
docs/ PLAN.md (plan and decisions log), BALANCE.md, CARD-WRITING.md, SCENARIO-WRITING.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The engine never touches the DOM and the UI never changes the state directly: it calls engine actions. That keeps the simulation testable headless.
|
||||||
|
|
||||||
|
## Test and tune
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go test ./server # validates every card, scenario, balance section, rules file and string
|
||||||
|
node tools/engine-check.mjs # engine rules, saves, tray, clock, game overs
|
||||||
|
node tools/balance-check.mjs 24 --check # plays full terms with bots and checks the difficulty bands
|
||||||
|
node tools/card-stats.mjs # overview of the card pool
|
||||||
|
node tools/add-cards.mjs <category> <batch.json> # merge a batch of new cards
|
||||||
|
node tools/scenario-check.mjs [id] # start-state report for a scenario
|
||||||
|
node tools/bal-edit.mjs path=value # edit balance.json keeping its layout
|
||||||
|
```
|
||||||
|
|
||||||
|
Guides for contributors (and agents): [docs/BALANCE.md](docs/BALANCE.md) for difficulty, [docs/CARD-WRITING.md](docs/CARD-WRITING.md) for new proposals, [docs/SCENARIO-WRITING.md](docs/SCENARIO-WRITING.md) for new countries.
|
||||||
|
|
||||||
|
Handy URL parameters for development (they skip the menu and never touch the real autosave): `?dev` (exposes `window.__game`), `?seed=N`, `?lang=fr`, `?pronouns=she`, `?skip=DAYS`, `?alloc=K`, `?card=ID`, `?msPerDay=N`, `?menu`.
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
Public domain, see [LICENSE](LICENSE). The fictional countries, people and events are invented.
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "8080:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
# background music: drop .mp3 files in ./music, no rebuild needed
|
||||||
|
- ./music:/music:ro
|
||||||
healthcheck:
|
healthcheck:
|
||||||
# distroless has no shell or curl, so the binary probes itself
|
# distroless has no shell or curl, so the binary probes itself
|
||||||
test: ["CMD", "/game", "-healthcheck"]
|
test: ["CMD", "/game", "-healthcheck"]
|
||||||
|
|||||||
+12
-3
@@ -15,7 +15,7 @@ The project folder is empty, so everything below is new code.
|
|||||||
| Backend | **Go, standard library only** (`net/http` + `embed`). It serves static files and has a `/healthz` endpoint. No game logic runs on the server. |
|
| Backend | **Go, standard library only** (`net/http` + `embed`). It serves static files and has a `/healthz` endpoint. No game logic runs on the server. |
|
||||||
| Frontend | **Vanilla JS ES modules + CSS, no bundler and no npm.** The only runtime dependency is zero: no framework, no chart library (charts are hand-drawn SVG). |
|
| Frontend | **Vanilla JS ES modules + CSS, no bundler and no npm.** The only runtime dependency is zero: no framework, no chart library (charts are hand-drawn SVG). |
|
||||||
| Fonts | Special Elite (typewriter text, **Apache-2.0**, not OFL), IM Fell English (headings, OFL) and Courier Prime (figures, OFL), stored with their licence files in `web/fonts/` so the game works offline. |
|
| Fonts | Special Elite (typewriter text, **Apache-2.0**, not OFL), IM Fell English (headings, OFL) and Courier Prime (figures, OFL), stored with their licence files in `web/fonts/` so the game works offline. |
|
||||||
| Time | **1 day = 10 s at 1× speed** (a full run is about 5 h). Speed controls: pause, 1×, 2×, 4×, plus a "slow" setting (60 s/day) in Settings. **The game pauses automatically** when the weekly card arrives, at the quarterly budget review, and on crisis telegrams. |
|
| Time | **1 day = 10 s at 1× speed** (a full run is about 5 h). Speed controls: pause, 1×, 2×, 4×, plus a "slow" setting (60 s/day) in Settings. Since M8b the weekly proposals pile up in a tray and the game only pauses when 10 are waiting, at the quarterly budget review (and the emergency session), and on danger telegrams. The game keeps running in a background tab. |
|
||||||
| Saves | Auto-save to `localStorage` every in-game day. 3 manual save slots. Export/import as a **plain base64 string** (and as a downloadable `.sav` file). |
|
| Saves | Auto-save to `localStorage` every in-game day. 3 manual save slots. Export/import as a **plain base64 string** (and as a downloadable `.sav` file). |
|
||||||
| Language | **English and French.** Every text field is `{ "en": "...", "fr": "..." }`, and there's a language switch in the menu. |
|
| Language | **English and French.** Every text field is `{ "en": "...", "fr": "..." }`, and there's a language switch in the menu. |
|
||||||
| Randomness | A seeded PRNG (mulberry32) whose state is stored in the save, so reloading a save doesn't re-roll the cards. |
|
| Randomness | A seeded PRNG (mulberry32) whose state is stored in the save, so reloading a save doesn't re-roll the cards. |
|
||||||
@@ -140,7 +140,7 @@ A weighted score over the indicators averaged across the whole term (not just fi
|
|||||||
|
|
||||||
## Time and the quarterly cycle
|
## Time and the quarterly cycle
|
||||||
- The main loop uses `requestAnimationFrame` with a time accumulator. A new day starts when the accumulator reaches `msPerDay / speed`.
|
- The main loop uses `requestAnimationFrame` with a time accumulator. A new day starts when the accumulator reaches `msPerDay / speed`.
|
||||||
- **Every Monday** a card is drawn and the game pauses. You can't resume until you answer it (stamp APPROVED or REJECTED).
|
- **Every Monday** a proposal is drawn and put in the in-tray; the game does not stop. You answer the one on top (stamp APPROVED or REJECTED). The game pauses when 10 proposals are waiting (not 10 weeks), until one is answered.
|
||||||
- **Every 13 weeks** the budget ledger opens and the game pauses. You can change the sector allocations and the three tax levers, with a live projection of the deficit and of debt over the next 4 quarters. The rest of the time the ledger is read-only.
|
- **Every 13 weeks** the budget ledger opens and the game pauses. You can change the sector allocations and the three tax levers, with a live projection of the deficit and of debt over the next 4 quarters. The rest of the time the ledger is read-only.
|
||||||
- Crisis events can arrive in the middle of a week as telegrams. They either need an answer or are information only.
|
- Crisis events can arrive in the middle of a week as telegrams. They either need an answer or are information only.
|
||||||
|
|
||||||
@@ -207,7 +207,7 @@ A weighted score over the indicators averaged across the whole term (not just fi
|
|||||||
- [x] **M6 Saves and menu**: auto-save, slots, export/import string and file, main menu, settings, language switch. Sound is not in the settings yet because the game has no sounds (M8, optional).
|
- [x] **M6 Saves and menu**: auto-save, slots, export/import string and file, main menu, settings, language switch. Sound is not in the settings yet because the game has no sounds (M8, optional).
|
||||||
- [x] **M7 Content production**: grow the card pool to at least 800 cards in batches by category, plus 50 crisis cards and the follow-up chains. Done: 864 cards, 117 of them follow-up-only, about 100 chains. Tools: `tools/add-cards.mjs`, `tools/card-stats.mjs`, `tools/cardfmt.mjs`, guide in `docs/CARD-WRITING.md`.
|
- [x] **M7 Content production**: grow the card pool to at least 800 cards in batches by category, plus 50 crisis cards and the follow-up chains. Done: 864 cards, 117 of them follow-up-only, about 100 chains. Tools: `tools/add-cards.mjs`, `tools/card-stats.mjs`, `tools/cardfmt.mjs`, guide in `docs/CARD-WRITING.md`.
|
||||||
- [x] **M8 Balance**: the difficulty is now measured and gated. Passive play is risky, a balanced plan wins, bold play pays up to a point and extremes end in a fall. Tools `tools/balance-check.mjs` (with `--check`), `balance-search.mjs`, `bal-edit.mjs`; guide in `docs/BALANCE.md`.
|
- [x] **M8 Balance**: the difficulty is now measured and gated. Passive play is risky, a balanced plan wins, bold play pays up to a point and extremes end in a fall. Tools `tools/balance-check.mjs` (with `--check`), `balance-search.mjs`, `bal-edit.mjs`; guide in `docs/BALANCE.md`.
|
||||||
- [ ] **M8b Polish (optional)**: animations, sounds (typewriter, stamp, telegraph; off by default), a second scenario, a repo README.
|
- [x] **M8b Polish**: animations, synthesised sounds, a dial clock that turns once per day, background play, a tray of up to 10 waiting proposals, a one-column phone layout with a shelf, the rules in EN and FR (also shown in the game), a README and a scenario authoring guide. A second scenario itself is not written: `docs/SCENARIO-WRITING.md` is the brief for agents to create them.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
- `docker compose up --build` → open http://localhost:8080. Check that the menu loads, a new game starts, days advance at 10 s, and 4× speed works.
|
- `docker compose up --build` → open http://localhost:8080. Check that the menu loads, a new game starts, days advance at 10 s, and 4× speed works.
|
||||||
@@ -254,3 +254,12 @@ A weighted score over the indicators averaged across the whole term (not just fi
|
|||||||
- 2026-10-25 (M8): Cards react to the player's choices. New condition keys `ratio:<sector>` and `tax:<id>` (engine, Go validator, guide). 50 new crisis cards: 34 backlash (`cri_bk_*`: police state, army excess or neglect, high and low taxes, debt and rating, starved services, waste) and 16 windfall (`cri_wf_*`: sustained high sector quality). The pool is 914 cards. Windfalls fire about 1.1 times per run for the balanced plan and 2.6 for the 1.5x plan; backlash fires 2 to 3 times per run for police state, tax hike and spendthrift and almost never for the milder versions.
|
- 2026-10-25 (M8): Cards react to the player's choices. New condition keys `ratio:<sector>` and `tax:<id>` (engine, Go validator, guide). 50 new crisis cards: 34 backlash (`cri_bk_*`: police state, army excess or neglect, high and low taxes, debt and rating, starved services, waste) and 16 windfall (`cri_wf_*`: sustained high sector quality). The pool is 914 cards. Windfalls fire about 1.1 times per run for the balanced plan and 2.6 for the 1.5x plan; backlash fires 2 to 3 times per run for police state, tax hike and spendthrift and almost never for the milder versions.
|
||||||
- 2026-10-25 (M8): Result, 24 seeds each, human-like card answers: random 79 % survive (grade D); planner 100 % and score 61 (A in 17 % of runs); the same plan at 1.5x boldness 100 % with A in 29 %; at 2x 63 % survive; at 3x default every time; ruin, starve, deep cuts, tyrant 0 %; spendthrift 33 %; mild versions of each idea 75 to 100 %. The best budget the search found scores about 63 to 65. `balance-check --check` encodes these bands. Soft spots: S is rare, unrest runs slightly positive in the card pool, blanket austerity is fatal.
|
- 2026-10-25 (M8): Result, 24 seeds each, human-like card answers: random 79 % survive (grade D); planner 100 % and score 61 (A in 17 % of runs); the same plan at 1.5x boldness 100 % with A in 29 %; at 2x 63 % survive; at 3x default every time; ruin, starve, deep cuts, tyrant 0 %; spendthrift 33 %; mild versions of each idea 75 to 100 %. The best budget the search found scores about 63 to 65. `balance-check --check` encodes these bands. Soft spots: S is rare, unrest runs slightly positive in the card pool, blanket austerity is fatal.
|
||||||
- 2026-10-25 (M8): Newspaper headlines for a global slump or boom. The ledger hint says needs grow yearly. Dev parameter `?untilAlert` stops a fast-forward at the first danger warning. Engine checks cover the world cycle, growing needs, the new condition keys, coup from neglect, the emergency session with its cooldown and the version 1 migration.
|
- 2026-10-25 (M8): Newspaper headlines for a global slump or boom. The ledger hint says needs grow yearly. Dev parameter `?untilAlert` stops a fast-forward at the first danger warning. Engine checks cover the world cycle, growing needs, the new condition keys, coup from neglect, the emergency session with its cooldown and the version 1 migration.
|
||||||
|
- 2026-10-25 (M8b): The in-tray. `state.pendingCard` became `state.inbox` (oldest first, entries `{id, day, category}`) and `pending.inboxFull` was added (state version 3, migration from version 2 turns a pending card into a one-card tray). `advanceDay` still raises `pending.weeklyCard` on a Monday, but it is momentary: the caller answers it at once with `ensureCard()`, which queues the proposal and sets `inboxFull` when the tray holds `cards.inboxMax` (10). `answerCard(state, bal, cards, choice, id?)` answers the top one or a chosen one. A card in the tray is not drawn twice and counts for the "same category recently" penalty. The balance harness is unchanged: its bots still answer at once.
|
||||||
|
- 2026-10-25 (M8b): Time and background play. The dial on the calendar and the small one on the phone shelf turn once per in-game day; the hand reads the clock accumulator, so it follows the speed. The accumulator is kept across pauses (it used to be reset). The loop is `step(now)`, called by animation frames while visible and by a worker heartbeat (`js/timer-worker.js`, every 100 ms) while hidden, so the game runs in a background tab; a long gap (a frozen tab waking up) is caught up within `time.catchUpMs` (60 s). A hidden tab skips the drawing, still autosaves, and shows "● (n) The Desk" in the title when it is waiting for the player. A new danger alert, review, full tray or new proposal plays a sound.
|
||||||
|
- 2026-10-25 (M8b): Sounds are synthesised with the Web Audio API (no files): stamp, paper, page, telegram, alert, chime, tick, click, fall, fanfare. Settings gained `sound` (default on) and `volume` (default 0.6). Motion lives in `css/anim.css` and is switched off by `prefers-reduced-motion`.
|
||||||
|
- 2026-10-25 (M8b): Phone layout (`css/mobile.css`, up to 900 px wide): one paper at a time, picked with a shelf along the top (date and dial, a speed button that cycles pause, 1x, 2x, 4x, then Tray with a count badge, Briefing, News, Calendar, Menu, and a Ledger tab while a review is open). The ledger takes the screen when a review opens and returns to the tray after signing. A full tray switches to the tray view.
|
||||||
|
- 2026-10-25 (M8b): Rules. `web/data/rules/rules.en.md` and `rules.fr.md` are plain Markdown, shown in the game (Menu, Rules) by a small renderer, `js/ui/markdown.js`, which escapes everything and supports headings, paragraphs, lists, bold, italic, code, tables, quotes and rules. `go test` checks that both files use only that subset and have the same structure. Scenario-only cards: optional card field `scenarios` (a whitelist of scenario ids, checked by the Go tests, honoured by `drawCard`). New tools: `scenario-check.mjs` and `balance-check.mjs --scenario <id>` with an optional `<id>.targets.json`.
|
||||||
|
- 2026-10-25 (M8b): Browser checks (headless Edge, desktop and a 390x844 phone): the tray fills to 10 on day 63 and stops, answering one resumes the clock; with animation frames disabled and the page marked hidden, the worker alone advanced the game to day 63 and stopped at the full tray; the phone shows exactly one paper per tab with no horizontal overflow; the ledger, menu, rules and telegram fit; all 13 sounds play without errors.
|
||||||
|
- 2026-10-25 (M8c, audio pass): Sounds were softened: slower attacks, longer fades, no energy below about 140 Hz (the stamp used to thump at 55 Hz), a master chain of high-pass, low-pass and a gentle limiter, and a short synthetic room reverb under everything so sounds melt away. Settings gained a mixer: one level per sound group (stamps, paper, page turns, signing, telegrams, danger warnings, full-tray chime, daily tick, countdown clock, buttons, end of term) plus the overall volume and the music, all stored in `settings.mix` (0 to 1) with a reset; releasing a slider plays that sound. The checkbox "Sounds" mutes the effects only; the music has its own on/off key.
|
||||||
|
- 2026-10-25 (M8c): Countdown. On the day before a new proposal (the day that precedes each Monday, which is also the day before each budget review) a small wooden clock ticks while that day runs, 20 ticks per day, 10 at 4x, following the dial. The ordinary daily tick is skipped that day. Nothing ticks while the game waits, the menu is open or the tab is hidden.
|
||||||
|
- 2026-10-25 (M8c): Music. The server lists `MUSIC_DIR` (default `music`, mounted from ./music by docker-compose) at `/music/index.json` (non-empty .mp3 files, sorted, never an error) and serves the files with byte ranges, with no directory listing, dot files or way out of the folder (Go tests). `js/ui/music.js` plays a shuffled bag with no immediate repeat, previous and next, skips unreadable files, and starts on the first click or key press if the music was left on (`settings.musicOn`; stop and pause turn it off, play and next turn it on). `js/ui/walkman.js` is the player on the desk: a silver cassette with two reels that turn while playing, the title on the label (it scrolls when long), a red LED and five keys. On the phone it sits under the calendar in the Calendar view. With no tracks it reads "No tape". `music/placeholder.mp3` is an empty placeholder.
|
||||||
|
|||||||
@@ -145,8 +145,8 @@ Most of the pool is shared. A scenario can add cards visible only in that scenar
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `go test ./server` | scenario index (id equals the file name, localized name, listed and present), card schema including `scenarios`, i18n. **The economy, sectors, indicators and taxes of a scenario other than `default` are not covered by Go tests**: use the tools below. |
|
| `go test ./server` | scenario index (id equals the file name, localized name, listed and present), card schema including `scenarios`, i18n. **The economy, sectors, indicators and taxes of a scenario other than `default` are not covered by Go tests**: use the tools below. |
|
||||||
| `node tools/engine-check.mjs` | engine rules (uses `default.json`) |
|
| `node tools/engine-check.mjs` | engine rules (uses `default.json`) |
|
||||||
| `node tools/scenario-check.mjs <id>` (new) | prints the start fiscal picture (revenue, sector spending, interest, deficit, debt) and a one-year hands-off drift; warns when the start is far from balanced. The draft warns for a deficit above 2, debt above 110, sector ratios outside 0.5 to 1.5, indicators outside 5 to 95, and exits with 1 on any warning. |
|
| `node tools/scenario-check.mjs <id>` | prints the start fiscal picture (revenue, sector spending, interest, deficit, debt) and a one-year hands-off drift; warns when the start is far from balanced. The draft warns for a deficit above 2, debt above 110, sector ratios outside 0.5 to 1.5, indicators outside 5 to 95, and exits with 1 on any warning. |
|
||||||
| `node tools/balance-check.mjs 24 --check --scenario <id>` (new) | plays the scenario with the standard player types and compares them to the bands in `balance-check.mjs` (about 30 s) |
|
| `node tools/balance-check.mjs 24 --check --scenario <id>` | plays the scenario with the standard player types and compares them to the bands in `balance-check.mjs` (about 30 s) |
|
||||||
|
|
||||||
Compare the hands-off drift with the default (about -5 on opinion, stability and economy, +8 unrest in a year). A scenario that
|
Compare the hands-off drift with the default (about -5 on opinion, stability and economy, +8 unrest in a year). A scenario that
|
||||||
collapses or ignores a year of inaction was built on the wrong numbers.
|
collapses or ignores a year of inaction was built on the wrong numbers.
|
||||||
@@ -190,7 +190,7 @@ near +0.5); run `scenario-check` on the real values.
|
|||||||
|
|
||||||
## Assumptions
|
## Assumptions
|
||||||
|
|
||||||
- The `scenarios` card field, `scenario-check.mjs` and `--scenario` behave as specified; the draft code in `tools/` was read for thresholds and may change.
|
- The `scenarios` card field, `scenario-check.mjs` and `--scenario` exist and were exercised on the default scenario only; a second scenario has not been played yet.
|
||||||
- The targets file is read as `{ "notes": "...", "targets": { player: {...} } }` (what `balance-check.mjs` reads today), and a player entry replaces the default entry whole (shallow merge), so restate every band you keep.
|
- The targets file is read as `{ "notes": "...", "targets": { player: {...} } }` (what `balance-check.mjs` reads today), and a player entry replaces the default entry whole (shallow merge), so restate every band you keep.
|
||||||
- Go tests currently load only `default.json` for economy, sector and card-condition checks.
|
- Go tests currently load only `default.json` for economy, sector and card-condition checks.
|
||||||
- Shared card text names Valdoria; the guide treats this as a known limit, not something a scenario can fix.
|
- Shared card text names Valdoria; the guide treats this as a known limit, not something a scenario can fix.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,10 @@
|
|||||||
|
# Music
|
||||||
|
|
||||||
|
Drop `.mp3` files here. The game's cassette player (bottom of the desk) plays them in random order.
|
||||||
|
|
||||||
|
- Only `.mp3` files are used. The file name becomes the title on the cassette: `03_Evening_Report.mp3` shows as "Evening Report".
|
||||||
|
- No rebuild needed. With Docker this folder is mounted read-only into the container; add files, then reload the page.
|
||||||
|
- `placeholder.mp3` is an empty file that keeps the folder in git and in Docker. Empty files are ignored, so the player shows "No tape" until a real track is added. You can delete it once you have music.
|
||||||
|
- Use music you have the right to use. Nothing here is shipped with the game.
|
||||||
|
|
||||||
|
Running without Docker: `go run ./server` reads `./music` by default; set `MUSIC_DIR` to use another folder.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 847 KiB |
+59
-3
@@ -1,10 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"countrygame/web"
|
"countrygame/web"
|
||||||
)
|
)
|
||||||
@@ -24,19 +28,31 @@ func main() {
|
|||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Background music: .mp3 files read live from this folder, so adding a track needs no rebuild.
|
||||||
|
musicDir := os.Getenv("MUSIC_DIR")
|
||||||
|
if musicDir == "" {
|
||||||
|
musicDir = "music"
|
||||||
|
}
|
||||||
|
|
||||||
root, err := fs.Sub(web.Files, ".")
|
root, err := fs.Sub(web.Files, ".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("country game listening on :%s (music folder %q)", port, musicDir)
|
||||||
|
log.Fatal(http.ListenAndServe(":"+port, newMux(root, musicDir)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// newMux serves the embedded game, /healthz, and the music folder under /music/.
|
||||||
|
func newMux(root fs.FS, musicDir string) *http.ServeMux {
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("/music/index.json", musicIndex(musicDir))
|
||||||
|
mux.Handle("/music/", http.StripPrefix("/music/", noCache(http.FileServer(noListing{http.Dir(musicDir)}))))
|
||||||
mux.Handle("/", noCache(http.FileServer(http.FS(root))))
|
mux.Handle("/", noCache(http.FileServer(http.FS(root))))
|
||||||
|
return mux
|
||||||
log.Printf("country game listening on :%s", port)
|
|
||||||
log.Fatal(http.ListenAndServe(":"+port, mux))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// noCache keeps iteration simple: data and scripts are always revalidated.
|
// noCache keeps iteration simple: data and scripts are always revalidated.
|
||||||
@@ -46,3 +62,43 @@ func noCache(next http.Handler) http.Handler {
|
|||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// noListing hides directory listings and dot files: only the tracks themselves are served.
|
||||||
|
type noListing struct{ fs http.FileSystem }
|
||||||
|
|
||||||
|
func (n noListing) Open(name string) (http.File, error) {
|
||||||
|
if strings.Contains(filepath.Base(name), "..") || strings.HasPrefix(filepath.Base(name), ".") {
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
f, err := n.fs.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info, err := f.Stat(); err != nil || info.IsDir() {
|
||||||
|
f.Close()
|
||||||
|
return nil, os.ErrNotExist
|
||||||
|
}
|
||||||
|
return f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// musicIndex lists the playable tracks: non-empty .mp3 files (the empty placeholder is skipped), sorted by name.
|
||||||
|
// A missing folder is not an error: the game simply shows an empty tape deck.
|
||||||
|
func musicIndex(dir string) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
tracks := []string{}
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
for _, e := range entries {
|
||||||
|
name := e.Name()
|
||||||
|
if e.IsDir() || strings.HasPrefix(name, ".") || !strings.EqualFold(filepath.Ext(name), ".mp3") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if info, err := e.Info(); err == nil && info.Size() > 0 {
|
||||||
|
tracks = append(tracks, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(tracks)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"tracks": tracks})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func musicServer(t *testing.T) (*httptest.Server, string) {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
write := func(name, content string) {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write("b_second.MP3", "not really audio, but not empty")
|
||||||
|
write("a_first.mp3", "0123456789")
|
||||||
|
write("placeholder.mp3", "") // the empty placeholder must not be offered
|
||||||
|
write("notes.txt", "hello")
|
||||||
|
write(".hidden.mp3", "secret")
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
site := fstest.MapFS{"index.html": {Data: []byte("<h1>game</h1>")}}
|
||||||
|
return httptest.NewServer(newMux(site, dir)), dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func get(t *testing.T, url string, header ...string) (*http.Response, string) {
|
||||||
|
t.Helper()
|
||||||
|
req, _ := http.NewRequest("GET", url, nil)
|
||||||
|
for i := 0; i+1 < len(header); i += 2 {
|
||||||
|
req.Header.Set(header[i], header[i+1])
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
n, _ := resp.Body.Read(buf)
|
||||||
|
return resp, string(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMusicFolder(t *testing.T) {
|
||||||
|
srv, _ := musicServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// the index offers only real, non-empty mp3 files, sorted
|
||||||
|
resp, body := get(t, srv.URL+"/music/index.json")
|
||||||
|
var idx struct{ Tracks []string }
|
||||||
|
if err := json.Unmarshal([]byte(body), &idx); err != nil {
|
||||||
|
t.Fatalf("index.json: %v (%s)", err, body)
|
||||||
|
}
|
||||||
|
if want := []string{"a_first.mp3", "b_second.MP3"}; !reflect.DeepEqual(idx.Tracks, want) {
|
||||||
|
t.Errorf("tracks = %v, want %v", idx.Tracks, want)
|
||||||
|
}
|
||||||
|
if resp.Header.Get("Cache-Control") != "no-store" {
|
||||||
|
t.Error("the index must not be cached: tracks are added without a rebuild")
|
||||||
|
}
|
||||||
|
|
||||||
|
// a track is served, and byte ranges work (the browser needs them to seek)
|
||||||
|
if r, b := get(t, srv.URL+"/music/a_first.mp3"); r.StatusCode != 200 || b != "0123456789" {
|
||||||
|
t.Errorf("track: %d %q", r.StatusCode, b)
|
||||||
|
}
|
||||||
|
if r, b := get(t, srv.URL+"/music/a_first.mp3", "Range", "bytes=2-4"); r.StatusCode != 206 || b != "234" {
|
||||||
|
t.Errorf("range: %d %q", r.StatusCode, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nothing else leaks: no listing, no dot files, no sub folders, no way out of the folder
|
||||||
|
for _, path := range []string{"/music/", "/music/.hidden.mp3", "/music/sub", "/music/..%2Fmain.go", "/music/%2e%2e/main.go"} {
|
||||||
|
if r, _ := get(t, srv.URL+path); r.StatusCode == 200 {
|
||||||
|
t.Errorf("%s must not be served", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// the game itself is still served
|
||||||
|
if r, b := get(t, srv.URL+"/"); r.StatusCode != 200 || b != "<h1>game</h1>" {
|
||||||
|
t.Errorf("site: %d %q", r.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMusicFolderMissing(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(newMux(fstest.MapFS{}, filepath.Join(t.TempDir(), "nope")))
|
||||||
|
defer srv.Close()
|
||||||
|
resp, body := get(t, srv.URL+"/music/index.json")
|
||||||
|
if resp.StatusCode != 200 || body != "{\"tracks\":[]}\n" {
|
||||||
|
t.Errorf("a missing music folder should give an empty list, got %d %q", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"countrygame/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Markdown the in-game renderer (web/js/ui/markdown.js) does not support.
|
||||||
|
var unsupportedMarkdown = []*regexp.Regexp{
|
||||||
|
regexp.MustCompile("(?m)^```"), // code blocks
|
||||||
|
regexp.MustCompile(`!\[`), // images
|
||||||
|
regexp.MustCompile(`\]\(`), // links
|
||||||
|
regexp.MustCompile(`</?[a-zA-Z]+[^>]*>`), // HTML
|
||||||
|
regexp.MustCompile(`(?m)^[ \t]+[-*] `), // nested lists
|
||||||
|
}
|
||||||
|
|
||||||
|
// rulesShape counts the building blocks of a rules file so the two languages can be compared.
|
||||||
|
func rulesShape(src string) map[string]int {
|
||||||
|
counts := map[string]int{}
|
||||||
|
inTable := false
|
||||||
|
for _, line := range strings.Split(strings.ReplaceAll(src, "\r\n", "\n"), "\n") {
|
||||||
|
if strings.HasPrefix(line, "|") {
|
||||||
|
if !inTable {
|
||||||
|
counts["tables"]++
|
||||||
|
}
|
||||||
|
inTable = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
inTable = false
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "### "):
|
||||||
|
counts["h3"]++
|
||||||
|
case strings.HasPrefix(line, "## "):
|
||||||
|
counts["h2"]++
|
||||||
|
case strings.HasPrefix(line, "# "):
|
||||||
|
counts["h1"]++
|
||||||
|
case strings.HasPrefix(line, "- "):
|
||||||
|
counts["bullets"]++
|
||||||
|
case strings.HasPrefix(line, ">"):
|
||||||
|
counts["quotes"]++
|
||||||
|
case strings.TrimSpace(line) == "---":
|
||||||
|
counts["rules"]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rules are shown in the game by a tiny Markdown renderer, so the files may only use what it supports,
|
||||||
|
// and the English and French versions must have the same shape.
|
||||||
|
func TestRulesFiles(t *testing.T) {
|
||||||
|
files := map[string]string{}
|
||||||
|
for _, lang := range []string{"en", "fr"} {
|
||||||
|
raw, err := web.Files.ReadFile("data/rules/rules." + lang + ".md")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rules.%s.md: %v", lang, err)
|
||||||
|
}
|
||||||
|
files[lang] = string(raw)
|
||||||
|
for _, bad := range unsupportedMarkdown {
|
||||||
|
if bad.MatchString(files[lang]) {
|
||||||
|
t.Errorf("rules.%s.md uses Markdown the in-game renderer does not support: %s", lang, bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
en, fr := rulesShape(files["en"]), rulesShape(files["fr"])
|
||||||
|
if en["h1"] != 1 {
|
||||||
|
t.Errorf("the rules need exactly one # title, found %d", en["h1"])
|
||||||
|
}
|
||||||
|
for _, k := range []string{"h1", "h2", "h3", "tables", "bullets", "quotes", "rules"} {
|
||||||
|
if en[k] != fr[k] {
|
||||||
|
t.Errorf("rules structure differs between languages: %s en=%d fr=%d", k, en[k], fr[k])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if en["h2"] < 8 {
|
||||||
|
t.Errorf("the rules look too short: only %d sections", en["h2"])
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-4
@@ -414,11 +414,11 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
mem.set(KEYS.slot(2), "{not json"); assert.equal(readSave(store, KEYS.slot(2)), null, "corrupt slot reads as empty");
|
mem.set(KEYS.slot(2), "{not json"); assert.equal(readSave(store, KEYS.slot(2)), null, "corrupt slot reads as empty");
|
||||||
mem.set(KEYS.slot(3), JSON.stringify({ nope: 1 })); assert.equal(readSave(store, KEYS.slot(3)), null);
|
mem.set(KEYS.slot(3), JSON.stringify({ nope: 1 })); assert.equal(readSave(store, KEYS.slot(3)), null);
|
||||||
|
|
||||||
assert.deepEqual(loadSettings(store), { lang: null, slow: false, sound: true, volume: 0.6 });
|
assert.deepEqual(loadSettings(store), { lang: null, slow: false, sound: true, volume: 0.6, mix: { stamp: 1, paper: 1, page: 1, sign: 1, telegram: 1, alert: 1, chime: 1, tick: 1, countdown: 1, click: 1, ending: 1, music: 0.5 }, musicOn: true });
|
||||||
saveSettings(store, { lang: "fr", slow: true, sound: false, volume: 0.25 });
|
saveSettings(store, { lang: "fr", slow: true, sound: false, volume: 0.25, mix: { stamp: 0.3, paper: 2, music: 0.8 }, musicOn: false });
|
||||||
assert.deepEqual(loadSettings(store), { lang: "fr", slow: true, sound: false, volume: 0.25 });
|
assert.deepEqual(loadSettings(store), { lang: "fr", slow: true, sound: false, volume: 0.25, mix: { stamp: 0.3, paper: 1, page: 1, sign: 1, telegram: 1, alert: 1, chime: 1, tick: 1, countdown: 1, click: 1, ending: 1, music: 0.8 }, musicOn: false }, "levels in range are kept, out-of-range ones fall back");
|
||||||
mem.set(KEYS.settings, JSON.stringify({ lang: "de", slow: "yes" }));
|
mem.set(KEYS.settings, JSON.stringify({ lang: "de", slow: "yes" }));
|
||||||
assert.deepEqual(loadSettings(store), { lang: null, slow: false, sound: true, volume: 0.6 }, "unknown values are ignored");
|
assert.deepEqual(loadSettings(store), { lang: null, slow: false, sound: true, volume: 0.6, mix: { stamp: 1, paper: 1, page: 1, sign: 1, telegram: 1, alert: 1, chime: 1, tick: 1, countdown: 1, click: 1, ending: 1, music: 0.5 }, musicOn: true }, "unknown values are ignored");
|
||||||
mem.set(KEYS.settings, JSON.stringify({ volume: 7, sound: "no" }));
|
mem.set(KEYS.settings, JSON.stringify({ volume: 7, sound: "no" }));
|
||||||
assert.equal(loadSettings(store).volume, 0.6, "an out-of-range volume falls back to the default");
|
assert.equal(loadSettings(store).volume, 0.6, "an out-of-range volume falls back to the default");
|
||||||
|
|
||||||
@@ -479,6 +479,40 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
|
|||||||
assert.equal(up.state.pendingCard, undefined);
|
assert.equal(up.state.pendingCard, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 13. scenario-only cards and the clock's catch-up cap
|
||||||
|
{
|
||||||
|
const mk = (id, extra = {}) => ({ id, category: "economy", doc: "memo", sender: "min_finance", title: { en: id, fr: id }, body: { en: "b", fr: "b" }, yes: { label: { en: "y", fr: "o" } }, no: { label: { en: "n", fr: "n" } }, ...extra });
|
||||||
|
const g = fresh(9); open(g);
|
||||||
|
assert.equal(E.drawCard(g, bal, [mk("elsewhere", { scenarios: ["other"] })]), null, "a card for another scenario is never drawn");
|
||||||
|
assert.equal(E.drawCard(g, bal, [mk("here", { scenarios: ["default"] })])?.id, "here");
|
||||||
|
assert.equal(E.drawCard(g, bal, [mk("everywhere")])?.id, "everywhere", "no scenarios field: every scenario");
|
||||||
|
|
||||||
|
const clock = E.createClock();
|
||||||
|
assert.equal(E.consumeClock(clock, 5000, 10000, 1), 0, "by default one step is capped at a second");
|
||||||
|
const wake = E.createClock();
|
||||||
|
assert.equal(E.consumeClock(wake, 45000, 10000, 1, 60000), 4, "a frozen tab catches up within the given cap");
|
||||||
|
const capped = E.createClock();
|
||||||
|
assert.equal(E.consumeClock(capped, 900000, 10000, 1, 60000), 6, "and no further than the cap");
|
||||||
|
assert.equal(E.consumeClock(E.createClock(), -50, 10000, 1, 60000), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. sound mixer keys and cassette titles
|
||||||
|
{
|
||||||
|
const { MIX_GROUPS, createAudio } = await import("../web/js/ui/audio.js");
|
||||||
|
const { prettyTitle } = await import("../web/js/ui/music.js");
|
||||||
|
const { MIX_KEYS, DEFAULT_MIX } = await import("../web/js/save/storage.js");
|
||||||
|
assert.deepEqual([...MIX_GROUPS, "music"], MIX_KEYS, "the mixer groups of audio.js and the saved levels list the same keys");
|
||||||
|
assert.equal(DEFAULT_MIX.music, 0.5);
|
||||||
|
// every sound belongs to a mixer group
|
||||||
|
const names = ["stampYes", "stampNo", "paper", "page", "sign", "telegram", "alert", "chime", "tick", "tock", "clockTick", "clockTock", "click", "fall", "fanfare"];
|
||||||
|
const { groupOf } = (() => { globalThis.document ??= { addEventListener() {} }; return createAudio(); })();
|
||||||
|
for (const n of names) assert.ok(MIX_GROUPS.includes(groupOf(n)), n + " has a mixer group");
|
||||||
|
assert.equal(prettyTitle("03_Evening_Report.mp3"), "Evening Report");
|
||||||
|
assert.equal(prettyTitle("Second-Tune.MP3"), "Second-Tune");
|
||||||
|
assert.equal(prettyTitle("12 - Night Train.mp3"), "Night Train");
|
||||||
|
assert.equal(prettyTitle("007.mp3"), "007", "a title made only of digits is kept");
|
||||||
|
}
|
||||||
|
|
||||||
// M8: world cycle, growing needs, card conditions on choices, coup from neglect, v1 saves
|
// M8: world cycle, growing needs, card conditions on choices, coup from neglect, v1 saves
|
||||||
{
|
{
|
||||||
// the world cycle moves, stays within bounds and is part of the saved state
|
// the world cycle moves, stays within bounds and is part of the saved state
|
||||||
|
|||||||
+2
-2
@@ -104,8 +104,8 @@ body {
|
|||||||
|
|
||||||
#side {
|
#side {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto auto minmax(0, 1fr);
|
||||||
gap: 22px;
|
gap: 18px;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
.cal__menu { display: block; width: 100%; margin-top: 8px; }
|
.cal__menu { display: block; width: 100%; margin-top: 8px; }
|
||||||
|
|
||||||
.menu__folder {
|
.menu__folder {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #a37a24 rgba(110, 75, 25, 0.25);
|
||||||
position: relative;
|
position: relative;
|
||||||
width: min(580px, 94%);
|
width: min(580px, 94%);
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
@@ -147,3 +149,18 @@
|
|||||||
.rules th { background: rgba(28, 42, 68, 0.1); font-weight: 700; }
|
.rules th { background: rgba(28, 42, 68, 0.1); font-weight: 700; }
|
||||||
.rules blockquote { margin: 8px 0; padding: 4px 10px; border-left: 4px solid var(--ink-red); background: rgba(255, 255, 255, 0.3); font-style: italic; }
|
.rules blockquote { margin: 8px 0; padding: 4px 10px; border-left: 4px solid var(--ink-red); background: rgba(255, 255, 255, 0.3); font-style: italic; }
|
||||||
.rules hr { border: 0; border-top: 1px dashed rgba(43, 36, 24, 0.5); margin: 14px 0; }
|
.rules hr { border: 0; border-top: 1px dashed rgba(43, 36, 24, 0.5); margin: 14px 0; }
|
||||||
|
|
||||||
|
/* ---- The sound mixer: a label, a slider and the level on each line ---- */
|
||||||
|
.mixer { display: grid; margin: 8px 0 12px; }
|
||||||
|
.mixer__row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) 3.2em;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-bottom: 1px dotted rgba(43, 36, 24, 0.4);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.mixer__row--master { margin-bottom: 4px; font-weight: 700; border-bottom: 2px solid var(--ink); }
|
||||||
|
.mixer__row input[type="range"] { width: 100%; min-height: 30px; margin: 0; accent-color: #a37a24; }
|
||||||
|
.mixer__row output { font-family: var(--font-num); font-weight: 700; text-align: right; }
|
||||||
|
|||||||
+11
-8
@@ -24,21 +24,24 @@
|
|||||||
#pad > * { grid-column: 1; }
|
#pad > * { grid-column: 1; }
|
||||||
|
|
||||||
/* Papers lie flat on a phone: a rotated sheet wastes the little width there is. */
|
/* Papers lie flat on a phone: a rotated sheet wastes the little width there is. */
|
||||||
.paper { transform: none; }
|
.paper, #walkman { transform: none; }
|
||||||
|
#side, #ledger, #dashboard, #folder { overflow-x: hidden; }
|
||||||
|
|
||||||
/* One paper at a time */
|
/* One paper at a time */
|
||||||
#dashboard, #folder, #ledger, #newspaper, #calendar { grid-row: 2; display: none; overflow-y: auto; }
|
#dashboard, #folder, #ledger { grid-row: 2; display: none; overflow-y: auto; }
|
||||||
#side { display: contents; }
|
#side { grid-row: 2; display: none; grid-template-rows: none; align-content: start; gap: 12px; overflow-y: auto; }
|
||||||
|
#calendar, #walkman, #newspaper { display: none; }
|
||||||
#pad[data-view="tray"] #folder,
|
#pad[data-view="tray"] #folder,
|
||||||
#pad[data-view="brief"] #dashboard,
|
#pad[data-view="brief"] #dashboard { display: block; }
|
||||||
#pad[data-view="news"] #newspaper,
|
#pad[data-view="clock"] #side,
|
||||||
#pad[data-view="clock"] #calendar { display: block; }
|
#pad[data-view="news"] #side { display: grid; }
|
||||||
#calendar { align-self: start; }
|
#pad[data-view="clock"] #calendar,
|
||||||
|
#pad[data-view="clock"] #walkman,
|
||||||
|
#pad[data-view="news"] #newspaper { display: block; }
|
||||||
|
|
||||||
/* The ledger is a view of its own while a review is open; the tabs still reach the other papers */
|
/* The ledger is a view of its own while a review is open; the tabs still reach the other papers */
|
||||||
#pad.is-ledger #folder { display: none; }
|
#pad.is-ledger #folder { display: none; }
|
||||||
#pad[data-view="ledger"] #ledger:not([hidden]) { display: block; }
|
#pad[data-view="ledger"] #ledger:not([hidden]) { display: block; }
|
||||||
#pad.is-ledger #side { display: contents; }
|
|
||||||
#ledger { grid-column: 1; }
|
#ledger { grid-column: 1; }
|
||||||
|
|
||||||
/* Menu and end screens cover the shelf too */
|
/* Menu and end screens cover the shelf too */
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
/* A small silver cassette player on the desk, with an orange brand stripe and chunky keys */
|
||||||
|
|
||||||
|
#walkman {
|
||||||
|
position: relative;
|
||||||
|
transform: rotate(var(--rot, 0deg));
|
||||||
|
filter: drop-shadow(0 8px 9px rgba(0, 0, 0, 0.55));
|
||||||
|
}
|
||||||
|
|
||||||
|
.walkman__body {
|
||||||
|
padding: 8px 10px 10px;
|
||||||
|
background: linear-gradient(160deg, #d3d6d8 0%, #acb0b4 45%, #92969a 100%);
|
||||||
|
border: 1px solid #5d6064;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.75), inset 0 -3px 6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.walkman__badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-family: var(--font-num);
|
||||||
|
color: #3a3d40;
|
||||||
|
}
|
||||||
|
.walkman__badge b { font-size: 12px; letter-spacing: 0.22em; color: #c4521f; }
|
||||||
|
.walkman__badge span { flex: 1; font-size: 9px; letter-spacing: 0.14em; text-transform: uppercase; opacity: 0.8; }
|
||||||
|
|
||||||
|
.walkman__led {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #5a1b14;
|
||||||
|
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.6);
|
||||||
|
transition: background 0.3s, box-shadow 0.3s;
|
||||||
|
}
|
||||||
|
.walkman.is-playing .walkman__led { background: #ff5a3c; box-shadow: 0 0 8px 2px rgba(255, 90, 60, 0.65); }
|
||||||
|
|
||||||
|
/* The cassette window: two reels around the label with the title */
|
||||||
|
.walkman__window {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 38px minmax(0, 1fr) 38px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: linear-gradient(#26292c, #15171a);
|
||||||
|
border: 2px solid #4b4e52;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: inset 0 2px 7px rgba(0, 0, 0, 0.85), 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
.walkman__reel { width: 38px; height: 38px; }
|
||||||
|
.walkman__tape { fill: #3b2b21; stroke: #1b130e; stroke-width: 1.5; }
|
||||||
|
.walkman__spokes line { stroke: #d9d3c1; stroke-width: 2.4; stroke-linecap: round; }
|
||||||
|
.walkman__spokes { transform-box: fill-box; transform-origin: 50% 50%; }
|
||||||
|
.walkman__hub { fill: #d9d3c1; stroke: #8a8472; stroke-width: 1; }
|
||||||
|
.walkman.is-playing .walkman__reel--left .walkman__spokes { animation: wm-spin 3.2s linear infinite; }
|
||||||
|
.walkman.is-playing .walkman__reel--right .walkman__spokes { animation: wm-spin 2.4s linear infinite; }
|
||||||
|
|
||||||
|
.walkman__label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 34px;
|
||||||
|
padding: 0 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #efe4c4 var(--paper-grain);
|
||||||
|
border-bottom: 3px solid #c4521f;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: inset 0 0 8px rgba(120, 90, 40, 0.3);
|
||||||
|
font-family: var(--font-type);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.walkman__title { display: block; white-space: nowrap; }
|
||||||
|
.walkman.is-empty .walkman__title { opacity: 0.55; font-style: italic; }
|
||||||
|
.walkman.is-playing .walkman__title.is-scroll { width: max-content; animation: wm-marquee 11s linear infinite; }
|
||||||
|
|
||||||
|
/* Vintage keys: raised piano keys that sink when pressed */
|
||||||
|
.walkman__keys { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; margin-top: 9px; }
|
||||||
|
.walkman__key {
|
||||||
|
height: 30px;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
color: #ebe7d8;
|
||||||
|
cursor: pointer;
|
||||||
|
background: linear-gradient(#63676b, #34373a);
|
||||||
|
border: 1px solid #1d1f21;
|
||||||
|
border-bottom-width: 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25), 0 1px 2px rgba(0, 0, 0, 0.5);
|
||||||
|
transition: transform 0.07s, border-bottom-width 0.07s, filter 0.15s;
|
||||||
|
}
|
||||||
|
.walkman__key svg { width: 20px; height: 14px; fill: currentColor; }
|
||||||
|
.walkman__key--play { box-shadow: inset 0 3px 0 #4f8f5a, 0 1px 2px rgba(0, 0, 0, 0.5); }
|
||||||
|
.walkman__key--stop { box-shadow: inset 0 3px 0 #c4521f, 0 1px 2px rgba(0, 0, 0, 0.5); }
|
||||||
|
.walkman__key:hover:not(:disabled) { filter: brightness(1.18); }
|
||||||
|
.walkman__key:active:not(:disabled),
|
||||||
|
.walkman__key[aria-pressed="true"] {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
transform: translateY(3px);
|
||||||
|
background: linear-gradient(#34373a, #4b4e52);
|
||||||
|
}
|
||||||
|
.walkman__key:focus-visible { outline: 2px solid #c4521f; outline-offset: 2px; }
|
||||||
|
.walkman__key:disabled { opacity: 0.4; cursor: default; }
|
||||||
|
|
||||||
|
@keyframes wm-spin { to { transform: rotate(360deg); } }
|
||||||
|
@keyframes wm-marquee {
|
||||||
|
from { transform: translateX(40%); }
|
||||||
|
to { transform: translateX(-100%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.walkman.is-playing .walkman__spokes,
|
||||||
|
.walkman.is-playing .walkman__title.is-scroll { animation: none !important; }
|
||||||
|
.walkman__title { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
Placeholder so go:embed accepts this folder. Delete once real files exist.
|
|
||||||
@@ -162,6 +162,12 @@ The game **autosaves** in your browser, and the autosave is removed when a game
|
|||||||
| Y / N | Approve or reject the top proposal |
|
| Y / N | Approve or reject the top proposal |
|
||||||
| Esc | Open the menu (time stops while it is open) |
|
| Esc | Open the menu (time stops while it is open) |
|
||||||
|
|
||||||
|
### Sound and music
|
||||||
|
|
||||||
|
Soft sound effects accompany the desk: stamps, paper, telegrams. In **Settings → Sound mixer** every sound has its own slider, so you can turn down only what bothers you. On the day before each new proposal a small clock ticks while the dial turns.
|
||||||
|
|
||||||
|
The cassette player under the calendar plays the `.mp3` files of the `music` folder in random order. Its keys are previous, play, pause, stop and next. With no file in the folder it reads "No tape".
|
||||||
|
|
||||||
## Advice
|
## Advice
|
||||||
|
|
||||||
- Balance the books, but do not be shy: bold investment pays up to a point, then the debt calls.
|
- Balance the books, but do not be shy: bold investment pays up to a point, then the debt calls.
|
||||||
|
|||||||
@@ -162,6 +162,12 @@ Le jeu fait une **sauvegarde auto** dans votre navigateur, supprimée quand une
|
|||||||
| Y / N | Approuver ou rejeter la proposition du dessus |
|
| Y / N | Approuver ou rejeter la proposition du dessus |
|
||||||
| Échap | Ouvrir le menu (le temps s'arrête tant qu'il est ouvert) |
|
| Échap | Ouvrir le menu (le temps s'arrête tant qu'il est ouvert) |
|
||||||
|
|
||||||
|
### Sons et musique
|
||||||
|
|
||||||
|
De discrets effets sonores accompagnent le bureau : tampons, papier, télégrammes. Dans **Réglages → Table de mixage**, chaque son a son propre curseur : vous ne baissez que ce qui vous gêne. La veille de chaque nouveau dossier, une petite horloge fait tic-tac pendant que le cadran tourne.
|
||||||
|
|
||||||
|
Le lecteur de cassettes, sous le calendrier, joue dans un ordre aléatoire les fichiers `.mp3` du dossier `music`. Ses touches sont : précédent, lecture, pause, arrêt, suivant. Sans fichier dans le dossier, il affiche « Pas de cassette ».
|
||||||
|
|
||||||
## Conseils
|
## Conseils
|
||||||
|
|
||||||
- Équilibrez les comptes, mais sans timidité : l'investissement audacieux paie jusqu'à un certain point, puis la dette réclame son dû.
|
- Équilibrez les comptes, mais sans timidité : l'investissement audacieux paie jusqu'à un certain point, puis la dette réclame son dû.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<link rel="stylesheet" href="css/decision.css">
|
<link rel="stylesheet" href="css/decision.css">
|
||||||
<link rel="stylesheet" href="css/endgame.css">
|
<link rel="stylesheet" href="css/endgame.css">
|
||||||
<link rel="stylesheet" href="css/menu.css">
|
<link rel="stylesheet" href="css/menu.css">
|
||||||
|
<link rel="stylesheet" href="css/walkman.css">
|
||||||
<link rel="stylesheet" href="css/anim.css">
|
<link rel="stylesheet" href="css/anim.css">
|
||||||
<link rel="stylesheet" href="css/mobile.css">
|
<link rel="stylesheet" href="css/mobile.css">
|
||||||
</head>
|
</head>
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
<section id="menu" hidden aria-label="Menu"></section>
|
<section id="menu" hidden aria-label="Menu"></section>
|
||||||
<div id="side">
|
<div id="side">
|
||||||
<section id="calendar" class="paper paper--calendar"></section>
|
<section id="calendar" class="paper paper--calendar"></section>
|
||||||
|
<section id="walkman" class="walkman" aria-label="Cassette player"></section>
|
||||||
<section id="newspaper" class="paper paper--news"></section>
|
<section id="newspaper" class="paper paper--news"></section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+29
-1
@@ -202,5 +202,33 @@
|
|||||||
"rules.error": "The rule book could not be opened.",
|
"rules.error": "The rule book could not be opened.",
|
||||||
"settings.sound": "Sounds",
|
"settings.sound": "Sounds",
|
||||||
"settings.soundHint": "Soft effects: stamps, paper, telegrams.",
|
"settings.soundHint": "Soft effects: stamps, paper, telegrams.",
|
||||||
"settings.volume": "Volume"
|
"settings.volume": "Volume",
|
||||||
|
"walkman.title": "Cassette player",
|
||||||
|
"walkman.model": "Stereo cassette",
|
||||||
|
"walkman.prev": "Previous tape",
|
||||||
|
"walkman.play": "Play",
|
||||||
|
"walkman.pause": "Pause",
|
||||||
|
"walkman.stop": "Stop",
|
||||||
|
"walkman.next": "Next tape",
|
||||||
|
"walkman.empty": "No tape",
|
||||||
|
"walkman.stopped": "Stopped",
|
||||||
|
"walkman.tap": "Press play",
|
||||||
|
"walkman.hint": "Put .mp3 files in the music folder to load a tape.",
|
||||||
|
"settings.mixer": "Sound mixer",
|
||||||
|
"mixer.title": "Sound mixer",
|
||||||
|
"mixer.hint": "Slide each sound down to your taste. You hear the result at once.",
|
||||||
|
"mixer.reset": "Reset levels",
|
||||||
|
"mixer.master": "Overall volume",
|
||||||
|
"mixer.music": "Music",
|
||||||
|
"mixer.stamp": "Stamps",
|
||||||
|
"mixer.paper": "Paper",
|
||||||
|
"mixer.page": "Page turns",
|
||||||
|
"mixer.sign": "Signing the ledger",
|
||||||
|
"mixer.telegram": "Telegrams",
|
||||||
|
"mixer.alert": "Danger warnings",
|
||||||
|
"mixer.chime": "Full-tray chime",
|
||||||
|
"mixer.tick": "Daily tick",
|
||||||
|
"mixer.countdown": "Clock before a new proposal",
|
||||||
|
"mixer.click": "Buttons",
|
||||||
|
"mixer.ending": "End of term"
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-1
@@ -202,5 +202,33 @@
|
|||||||
"rules.error": "Le règlement n'a pas pu être ouvert.",
|
"rules.error": "Le règlement n'a pas pu être ouvert.",
|
||||||
"settings.sound": "Sons",
|
"settings.sound": "Sons",
|
||||||
"settings.soundHint": "Effets discrets : tampons, papier, télégrammes.",
|
"settings.soundHint": "Effets discrets : tampons, papier, télégrammes.",
|
||||||
"settings.volume": "Volume"
|
"settings.volume": "Volume",
|
||||||
|
"walkman.title": "Lecteur de cassettes",
|
||||||
|
"walkman.model": "Cassette stéréo",
|
||||||
|
"walkman.prev": "Piste précédente",
|
||||||
|
"walkman.play": "Lecture",
|
||||||
|
"walkman.pause": "Pause",
|
||||||
|
"walkman.stop": "Arrêt",
|
||||||
|
"walkman.next": "Piste suivante",
|
||||||
|
"walkman.empty": "Pas de cassette",
|
||||||
|
"walkman.stopped": "Arrêté",
|
||||||
|
"walkman.tap": "Appuyez sur lecture",
|
||||||
|
"walkman.hint": "Déposez des fichiers .mp3 dans le dossier music pour charger une cassette.",
|
||||||
|
"settings.mixer": "Table de mixage",
|
||||||
|
"mixer.title": "Table de mixage",
|
||||||
|
"mixer.hint": "Baissez chaque son selon votre goût. Le résultat s'entend aussitôt.",
|
||||||
|
"mixer.reset": "Rétablir les niveaux",
|
||||||
|
"mixer.master": "Volume général",
|
||||||
|
"mixer.music": "Musique",
|
||||||
|
"mixer.stamp": "Tampons",
|
||||||
|
"mixer.paper": "Papier",
|
||||||
|
"mixer.page": "Pages tournées",
|
||||||
|
"mixer.sign": "Signature du registre",
|
||||||
|
"mixer.telegram": "Télégrammes",
|
||||||
|
"mixer.alert": "Alertes de danger",
|
||||||
|
"mixer.chime": "Carillon de corbeille pleine",
|
||||||
|
"mixer.tick": "Tic quotidien",
|
||||||
|
"mixer.countdown": "Horloge avant un nouveau dossier",
|
||||||
|
"mixer.click": "Boutons",
|
||||||
|
"mixer.ending": "Fin de mandat"
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-5
@@ -26,6 +26,9 @@ import { createEndgame } from "./ui/endgame.js";
|
|||||||
import { createMenu } from "./ui/menu.js";
|
import { createMenu } from "./ui/menu.js";
|
||||||
import { createShelf } from "./ui/shelf.js";
|
import { createShelf } from "./ui/shelf.js";
|
||||||
import { createAudio } from "./ui/audio.js";
|
import { createAudio } from "./ui/audio.js";
|
||||||
|
import { createMusic } from "./ui/music.js";
|
||||||
|
import { createWalkman } from "./ui/walkman.js";
|
||||||
|
import { DEFAULT_MIX } from "./save/storage.js";
|
||||||
|
|
||||||
const params = new URLSearchParams(location.search);
|
const params = new URLSearchParams(location.search);
|
||||||
const getJson = (url) => fetch(url).then((r) => r.json());
|
const getJson = (url) => fetch(url).then((r) => r.json());
|
||||||
@@ -54,7 +57,23 @@ let settings = loadSettings(storage);
|
|||||||
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
|
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
|
||||||
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
|
await initI18n(params.get("lang") ?? settings.lang ?? browserLang);
|
||||||
|
|
||||||
const audio = createAudio({ enabled: () => settings.sound, volume: () => settings.volume });
|
const audio = createAudio({ enabled: () => settings.sound, volume: () => settings.volume, mix: () => settings.mix });
|
||||||
|
|
||||||
|
// Background music from the music folder, driven by the cassette player on the desk.
|
||||||
|
const music = createMusic({
|
||||||
|
getVolume: () => settings.volume * settings.mix.music,
|
||||||
|
onUserChoice: (on) => { if (settings.musicOn !== on) applySettings({ musicOn: on }); },
|
||||||
|
});
|
||||||
|
let gestured = false;
|
||||||
|
const startMusic = () => gestured && music.unlockedByGesture(settings.musicOn);
|
||||||
|
for (const ev of ["pointerdown", "keydown"]) {
|
||||||
|
document.addEventListener(ev, (e) => {
|
||||||
|
if (e.target.closest?.("#walkman")) return; // pressing a walkman key is its own choice
|
||||||
|
gestured = true;
|
||||||
|
startMusic();
|
||||||
|
}, { passive: true });
|
||||||
|
}
|
||||||
|
music.load().then(startMusic);
|
||||||
|
|
||||||
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
|
const DEV_PARAMS = ["seed", "pronouns", "alloc", "skip", "card", "dev", "new"];
|
||||||
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
|
const devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
|
||||||
@@ -79,9 +98,11 @@ const el = {
|
|||||||
news: document.getElementById("newspaper"),
|
news: document.getElementById("newspaper"),
|
||||||
menu: document.getElementById("menu"),
|
menu: document.getElementById("menu"),
|
||||||
shelf: document.getElementById("shelf"),
|
shelf: document.getElementById("shelf"),
|
||||||
|
walkman: document.getElementById("walkman"),
|
||||||
};
|
};
|
||||||
const pad = document.getElementById("pad");
|
const pad = document.getElementById("pad");
|
||||||
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
|
for (const id of ["dash", "folder", "cal", "news"]) tilt(el[id], id);
|
||||||
|
tilt(el.walkman, "walkman", 0.5);
|
||||||
|
|
||||||
// A new week brings a proposal to the tray; with none eligible the week is simply empty.
|
// A new week brings a proposal to the tray; with none eligible the week is simply empty.
|
||||||
function prepareWeek() {
|
function prepareWeek() {
|
||||||
@@ -136,6 +157,7 @@ function build() {
|
|||||||
ledger: createLedger(el.ledger, scenario, { onConfirm: () => { audio.play("sign"); render(); } }),
|
ledger: createLedger(el.ledger, scenario, { onConfirm: () => { audio.play("sign"); render(); } }),
|
||||||
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
|
cal: createCalendar(el.cal, balance, { onSpeed, onMenu: openMenu }),
|
||||||
shelf: createShelf(el.shelf, balance, { getView: () => view, onView: setView, onSpeed, onMenu: openMenu }),
|
shelf: createShelf(el.shelf, balance, { getView: () => view, onView: setView, onSpeed, onMenu: openMenu }),
|
||||||
|
walkman: createWalkman(el.walkman, { music, onPress: () => audio.play("click") }),
|
||||||
news: createNewspaper(el.news, newsTpl),
|
news: createNewspaper(el.news, newsTpl),
|
||||||
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
|
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
|
||||||
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
|
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
|
||||||
@@ -218,7 +240,7 @@ function quitToMenu() {
|
|||||||
panels?.decision.destroy();
|
panels?.decision.destroy();
|
||||||
state = null;
|
state = null;
|
||||||
panels = null;
|
panels = null;
|
||||||
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news", "shelf"]) el[id].innerHTML = "";
|
for (const id of ["dash", "folder", "ledger", "alerts", "endgame", "cal", "news", "shelf", "walkman"]) el[id].innerHTML = "";
|
||||||
document.title = t("app.title");
|
document.title = t("app.title");
|
||||||
el.ledger.hidden = true;
|
el.ledger.hidden = true;
|
||||||
el.alerts.hidden = true;
|
el.alerts.hidden = true;
|
||||||
@@ -231,9 +253,12 @@ function quitToMenu() {
|
|||||||
|
|
||||||
function applySettings(patch) {
|
function applySettings(patch) {
|
||||||
const langChanged = patch.lang && patch.lang !== getLang();
|
const langChanged = patch.lang && patch.lang !== getLang();
|
||||||
settings = { ...settings, ...patch };
|
const { resetMix, ...rest } = patch;
|
||||||
|
settings = { ...settings, ...rest, mix: resetMix ? { ...DEFAULT_MIX } : { ...settings.mix, ...rest.mix } };
|
||||||
|
if (resetMix) settings.volume = 0.6;
|
||||||
saveSettings(storage, settings);
|
saveSettings(storage, settings);
|
||||||
if (patch.sound === true || patch.volume !== undefined) audio.play("click");
|
if (patch.sound === true) audio.play("click");
|
||||||
|
if (patch.volume !== undefined || patch.mix?.music !== undefined || resetMix) music.applyVolume();
|
||||||
if (!langChanged) return;
|
if (!langChanged) return;
|
||||||
setLang(patch.lang);
|
setLang(patch.lang);
|
||||||
document.title = t("app.title");
|
document.title = t("app.title");
|
||||||
@@ -241,6 +266,12 @@ function applySettings(patch) {
|
|||||||
menu.refresh();
|
menu.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The sound heard when a mixer slider is released.
|
||||||
|
const PREVIEW = {
|
||||||
|
master: "chime", stamp: "stampYes", paper: "paper", page: "page", sign: "sign", telegram: "telegram", alert: "alert",
|
||||||
|
chime: "chime", tick: "tick", countdown: "clockTick", click: "click", ending: "fanfare",
|
||||||
|
};
|
||||||
|
|
||||||
const menu = createMenu(el.menu, {
|
const menu = createMenu(el.menu, {
|
||||||
content,
|
content,
|
||||||
storage,
|
storage,
|
||||||
@@ -252,6 +283,7 @@ const menu = createMenu(el.menu, {
|
|||||||
onResume: closeMenu,
|
onResume: closeMenu,
|
||||||
onQuit: quitToMenu,
|
onQuit: quitToMenu,
|
||||||
onSettings: applySettings,
|
onSettings: applySettings,
|
||||||
|
onPreview: (key) => audio.play(PREVIEW[key] ?? "click"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -345,7 +377,7 @@ function step(now) {
|
|||||||
if (!r.advanced) break;
|
if (!r.advanced) break;
|
||||||
advanced = true;
|
advanced = true;
|
||||||
if (r.events.weeklyCard) prepareWeek();
|
if (r.events.weeklyCard) prepareWeek();
|
||||||
if (!document.hidden && state.speed <= 2) audio.play(state.day % 2 ? "tick" : "tock");
|
if (!document.hidden && state.speed <= 2 && !isDayBeforeIssue()) audio.play(state.day % 2 ? "tick" : "tock");
|
||||||
if (isWaiting(state) || state.over) break;
|
if (isWaiting(state) || state.over) break;
|
||||||
}
|
}
|
||||||
if (advanced) render();
|
if (advanced) render();
|
||||||
@@ -355,12 +387,27 @@ function step(now) {
|
|||||||
const devMsPerDay = devRun && params.has("msPerDay") ? Number(params.get("msPerDay")) : 0; // dev: faster days for tests
|
const devMsPerDay = devRun && params.has("msPerDay") ? Number(params.get("msPerDay")) : 0; // dev: faster days for tests
|
||||||
const msPerDay = () => devMsPerDay || (settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay);
|
const msPerDay = () => devMsPerDay || (settings.slow ? balance.time.slowMsPerDay : balance.time.msPerDay);
|
||||||
|
|
||||||
|
// The day before a new proposal arrives (the Sunday before each Monday), a small clock ticks while that day runs.
|
||||||
|
const isDayBeforeIssue = () => (state.day + 1) % balance.time.daysPerWeek === 0;
|
||||||
|
let lastCountdown = "";
|
||||||
|
|
||||||
|
function countdownTicks(p) {
|
||||||
|
if (!state || menuOpen || state.speed <= 0 || state.over || isWaiting(state) || document.hidden || !isDayBeforeIssue()) return;
|
||||||
|
const perDay = state.speed >= 4 ? 10 : 20; // the ticks follow the dial: faster at higher speeds
|
||||||
|
const n = Math.floor(p * perDay);
|
||||||
|
const key = `${state.day}:${n}`;
|
||||||
|
if (key === lastCountdown) return;
|
||||||
|
lastCountdown = key;
|
||||||
|
audio.play(n % 2 ? "clockTock" : "clockTick");
|
||||||
|
}
|
||||||
|
|
||||||
function frame(now) {
|
function frame(now) {
|
||||||
step(now);
|
step(now);
|
||||||
if (panels) {
|
if (panels) {
|
||||||
const p = Math.min(1, clock.acc / msPerDay());
|
const p = Math.min(1, clock.acc / msPerDay());
|
||||||
panels.cal.clockHand(p);
|
panels.cal.clockHand(p);
|
||||||
panels.shelf.clockHand(p);
|
panels.shelf.clockHand(p);
|
||||||
|
countdownTicks(p);
|
||||||
}
|
}
|
||||||
requestAnimationFrame(frame);
|
requestAnimationFrame(frame);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-3
@@ -88,7 +88,13 @@ export function readSave(store, key) {
|
|||||||
|
|
||||||
// ---- Settings ----
|
// ---- Settings ----
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = { lang: null, slow: false, sound: true, volume: 0.6 };
|
// The mixer: one level (0 to 1) per sound group and one for the music. Keep in step with MIX_GROUPS in ui/audio.js.
|
||||||
|
export const MIX_KEYS = ["stamp", "paper", "page", "sign", "telegram", "alert", "chime", "tick", "countdown", "click", "ending", "music"];
|
||||||
|
export const DEFAULT_MIX = Object.fromEntries(MIX_KEYS.map((k) => [k, k === "music" ? 0.5 : 1]));
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = { lang: null, slow: false, sound: true, volume: 0.6, mix: DEFAULT_MIX, musicOn: true };
|
||||||
|
|
||||||
|
const level = (v, fallback) => (typeof v === "number" && v >= 0 && v <= 1 ? v : fallback);
|
||||||
|
|
||||||
export function loadSettings(store) {
|
export function loadSettings(store) {
|
||||||
try {
|
try {
|
||||||
@@ -97,10 +103,12 @@ export function loadSettings(store) {
|
|||||||
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
|
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
|
||||||
slow: s.slow === true,
|
slow: s.slow === true,
|
||||||
sound: s.sound !== false,
|
sound: s.sound !== false,
|
||||||
volume: typeof s.volume === "number" && s.volume >= 0 && s.volume <= 1 ? s.volume : DEFAULT_SETTINGS.volume,
|
volume: level(s.volume, DEFAULT_SETTINGS.volume),
|
||||||
|
mix: Object.fromEntries(MIX_KEYS.map((k) => [k, level(s.mix?.[k], DEFAULT_MIX[k])])),
|
||||||
|
musicOn: s.musicOn !== false,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { ...DEFAULT_SETTINGS };
|
return { ...DEFAULT_SETTINGS, mix: { ...DEFAULT_MIX } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+99
-44
@@ -1,24 +1,63 @@
|
|||||||
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft
|
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft: slow
|
||||||
// (slow attacks, low levels, filtered noise) so it stays in the background of the desk.
|
// attacks, long fades, no deep bass, and a short room reverb under it all so sounds melt away
|
||||||
|
// instead of stopping. Each sound belongs to a mixer group the player can turn down on its own.
|
||||||
// Browsers keep audio locked until the first click or key press; unlock() resumes it.
|
// Browsers keep audio locked until the first click or key press; unlock() resumes it.
|
||||||
|
|
||||||
export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
// Mixer groups, in the order the mixer shows them.
|
||||||
|
export const MIX_GROUPS = ["stamp", "paper", "page", "sign", "telegram", "alert", "chime", "tick", "countdown", "click", "ending"];
|
||||||
|
|
||||||
|
const GROUP_OF = {
|
||||||
|
stampYes: "stamp", stampNo: "stamp", paper: "paper", page: "page", sign: "sign", telegram: "telegram",
|
||||||
|
alert: "alert", chime: "chime", tick: "tick", tock: "tick", clockTick: "countdown", clockTock: "countdown",
|
||||||
|
click: "click", fall: "ending", fanfare: "ending",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createAudio({ enabled = () => true, volume = () => 0.6, mix = () => ({}) } = {}) {
|
||||||
let ctx = null;
|
let ctx = null;
|
||||||
let master = null;
|
let master = null;
|
||||||
let noiseBuf = null;
|
let noiseBuf = null;
|
||||||
|
let out = null; // where the sound being built sends its notes (a gain node per play)
|
||||||
|
|
||||||
function context() {
|
function context() {
|
||||||
if (!ctx) {
|
if (ctx) return ctx;
|
||||||
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
|
||||||
if (!AC) return null;
|
if (!AC) return null;
|
||||||
try {
|
try {
|
||||||
ctx = new AC();
|
ctx = new AC();
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
|
||||||
master = ctx.createGain();
|
|
||||||
master.connect(ctx.destination);
|
|
||||||
}
|
}
|
||||||
|
// master -> no deep bass -> no harsh treble -> gentle limiter -> speakers, plus a soft room send
|
||||||
|
master = ctx.createGain();
|
||||||
|
const lowCut = ctx.createBiquadFilter();
|
||||||
|
lowCut.type = "highpass";
|
||||||
|
lowCut.frequency.value = 140;
|
||||||
|
const highCut = ctx.createBiquadFilter();
|
||||||
|
highCut.type = "lowpass";
|
||||||
|
highCut.frequency.value = 4800;
|
||||||
|
highCut.Q.value = 0.4;
|
||||||
|
const limiter = ctx.createDynamicsCompressor();
|
||||||
|
limiter.threshold.value = -22;
|
||||||
|
limiter.knee.value = 24;
|
||||||
|
limiter.ratio.value = 6;
|
||||||
|
limiter.attack.value = 0.01;
|
||||||
|
limiter.release.value = 0.35;
|
||||||
|
master.connect(lowCut).connect(highCut).connect(limiter).connect(ctx.destination);
|
||||||
|
|
||||||
|
const room = ctx.createConvolver();
|
||||||
|
const len = Math.floor(ctx.sampleRate * 1.5);
|
||||||
|
const ir = ctx.createBuffer(2, len, ctx.sampleRate);
|
||||||
|
for (let ch = 0; ch < 2; ch++) {
|
||||||
|
const d = ir.getChannelData(ch);
|
||||||
|
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.6);
|
||||||
|
}
|
||||||
|
room.buffer = ir;
|
||||||
|
const wet = ctx.createGain();
|
||||||
|
wet.gain.value = 0.28;
|
||||||
|
const roomTone = ctx.createBiquadFilter();
|
||||||
|
roomTone.type = "lowpass";
|
||||||
|
roomTone.frequency.value = 2600;
|
||||||
|
highCut.connect(wet).connect(room).connect(roomTone).connect(limiter);
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,8 +67,8 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
|||||||
}
|
}
|
||||||
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true });
|
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true });
|
||||||
|
|
||||||
// One note: a sine (or other) wave with an attack and an exponential fade.
|
// One note: a wave with an attack and an exponential fade.
|
||||||
function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) {
|
function tone(c, at, { freq, to, type = "sine", dur = 0.3, gain = 0.1, attack = 0.02 }) {
|
||||||
const o = c.createOscillator();
|
const o = c.createOscillator();
|
||||||
const g = c.createGain();
|
const g = c.createGain();
|
||||||
o.type = type;
|
o.type = type;
|
||||||
@@ -38,13 +77,13 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
|||||||
g.gain.setValueAtTime(0.0001, at);
|
g.gain.setValueAtTime(0.0001, at);
|
||||||
g.gain.linearRampToValueAtTime(gain, at + attack);
|
g.gain.linearRampToValueAtTime(gain, at + attack);
|
||||||
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||||
o.connect(g).connect(master);
|
o.connect(g).connect(out);
|
||||||
o.start(at);
|
o.start(at);
|
||||||
o.stop(at + dur + 0.05);
|
o.stop(at + dur + 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filtered noise: paper, pages, thumps and clicks.
|
// Filtered noise: paper, pages, soft impacts and ticks.
|
||||||
function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) {
|
function noise(c, at, { dur = 0.2, gain = 0.06, type = "bandpass", freq = 2000, to, q = 0.6, attack = 0.03 }) {
|
||||||
noiseBuf ??= (() => {
|
noiseBuf ??= (() => {
|
||||||
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||||
const d = b.getChannelData(0);
|
const d = b.getChannelData(0);
|
||||||
@@ -63,55 +102,66 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
|||||||
g.gain.setValueAtTime(0.0001, at);
|
g.gain.setValueAtTime(0.0001, at);
|
||||||
g.gain.linearRampToValueAtTime(gain, at + attack);
|
g.gain.linearRampToValueAtTime(gain, at + attack);
|
||||||
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
||||||
src.connect(f).connect(g).connect(master);
|
src.connect(f).connect(g).connect(out);
|
||||||
src.start(at);
|
src.start(at);
|
||||||
src.stop(at + dur + 0.05);
|
src.stop(at + dur + 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SOUNDS = {
|
const SOUNDS = {
|
||||||
|
// A padded "thup" rather than a thud: mid-range, slow to start, with a soft paper tail.
|
||||||
stampYes: (c, t) => {
|
stampYes: (c, t) => {
|
||||||
tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 });
|
tone(c, t, { freq: 230, to: 130, dur: 0.32, gain: 0.2, attack: 0.016 });
|
||||||
noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 });
|
noise(c, t, { type: "lowpass", freq: 1100, dur: 0.18, gain: 0.09, attack: 0.014, q: 0.4 });
|
||||||
noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 });
|
noise(c, t + 0.02, { freq: 2000, to: 1400, dur: 0.32, gain: 0.025, attack: 0.03 });
|
||||||
},
|
},
|
||||||
stampNo: (c, t) => {
|
stampNo: (c, t) => {
|
||||||
tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 });
|
tone(c, t, { freq: 190, to: 108, dur: 0.34, gain: 0.2, attack: 0.016 });
|
||||||
noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 });
|
noise(c, t, { type: "lowpass", freq: 900, dur: 0.2, gain: 0.09, attack: 0.014, q: 0.4 });
|
||||||
noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 });
|
noise(c, t + 0.02, { freq: 1700, to: 1200, dur: 0.32, gain: 0.025, attack: 0.03 });
|
||||||
},
|
},
|
||||||
paper: (c, t) => noise(c, t, { freq: 2600, to: 1200, dur: 0.17, gain: 0.1, attack: 0.04 }),
|
paper: (c, t) => noise(c, t, { freq: 2400, to: 1100, dur: 0.36, gain: 0.075, attack: 0.09 }),
|
||||||
page: (c, t) => {
|
page: (c, t) => {
|
||||||
noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 });
|
noise(c, t, { freq: 450, to: 2800, dur: 0.52, gain: 0.085, attack: 0.13 });
|
||||||
noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 });
|
noise(c, t + 0.4, { freq: 2200, dur: 0.18, gain: 0.035, attack: 0.03 });
|
||||||
},
|
},
|
||||||
sign: (c, t) => {
|
sign: (c, t) => {
|
||||||
noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 });
|
noise(c, t, { freq: 3200, to: 2400, dur: 0.42, gain: 0.04, q: 2, attack: 0.11 });
|
||||||
tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 });
|
tone(c, t + 0.44, { freq: 200, to: 125, dur: 0.28, gain: 0.15, attack: 0.02 });
|
||||||
|
noise(c, t + 0.44, { type: "lowpass", freq: 1000, dur: 0.14, gain: 0.06, attack: 0.012, q: 0.4 });
|
||||||
},
|
},
|
||||||
telegram: (c, t) => {
|
telegram: (c, t) => {
|
||||||
for (let i = 0; i < 4; i++) noise(c, t + i * 0.07, { type: "highpass", freq: 3200, dur: 0.02, gain: 0.1, attack: 0.002 });
|
for (let i = 0; i < 4; i++) noise(c, t + i * 0.09, { type: "highpass", freq: 3200, dur: 0.045, gain: 0.05, attack: 0.007 });
|
||||||
tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 });
|
tone(c, t + 0.44, { freq: 1320, dur: 1.6, gain: 0.07, attack: 0.04 });
|
||||||
tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 });
|
tone(c, t + 0.44, { freq: 1980, dur: 1.1, gain: 0.022, attack: 0.04 });
|
||||||
},
|
},
|
||||||
alert: (c, t) => {
|
alert: (c, t) => {
|
||||||
tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 });
|
tone(c, t, { freq: 233, dur: 0.95, gain: 0.12, type: "triangle", attack: 0.16 });
|
||||||
tone(c, t + 0.24, { freq: 175, dur: 0.55, gain: 0.2, type: "triangle", attack: 0.03 });
|
tone(c, t + 0.32, { freq: 175, dur: 1.25, gain: 0.12, type: "triangle", attack: 0.16 });
|
||||||
},
|
},
|
||||||
chime: (c, t) => {
|
chime: (c, t) => {
|
||||||
tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 });
|
tone(c, t, { freq: 659, dur: 1.4, gain: 0.08, attack: 0.06 });
|
||||||
tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 });
|
tone(c, t + 0.22, { freq: 880, dur: 1.8, gain: 0.065, attack: 0.06 });
|
||||||
|
},
|
||||||
|
tick: (c, t) => tone(c, t, { freq: 1000, to: 850, dur: 0.07, gain: 0.02, attack: 0.007 }),
|
||||||
|
tock: (c, t) => tone(c, t, { freq: 760, to: 650, dur: 0.07, gain: 0.02, attack: 0.007 }),
|
||||||
|
// The countdown on the day before a new proposal: a small wooden clock.
|
||||||
|
clockTick: (c, t) => {
|
||||||
|
tone(c, t, { freq: 1500, to: 1150, dur: 0.1, gain: 0.05, attack: 0.005 });
|
||||||
|
noise(c, t, { freq: 2800, q: 4, dur: 0.06, gain: 0.025, attack: 0.005 });
|
||||||
|
},
|
||||||
|
clockTock: (c, t) => {
|
||||||
|
tone(c, t, { freq: 1150, to: 880, dur: 0.1, gain: 0.05, attack: 0.005 });
|
||||||
|
noise(c, t, { freq: 2200, q: 4, dur: 0.06, gain: 0.025, attack: 0.005 });
|
||||||
},
|
},
|
||||||
tick: (c, t) => tone(c, t, { freq: 1100, to: 900, dur: 0.035, gain: 0.03, attack: 0.002 }),
|
|
||||||
tock: (c, t) => tone(c, t, { freq: 820, to: 700, dur: 0.035, gain: 0.03, attack: 0.002 }),
|
|
||||||
click: (c, t) => {
|
click: (c, t) => {
|
||||||
tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 });
|
tone(c, t, { freq: 800, to: 600, dur: 0.09, gain: 0.045, attack: 0.007 });
|
||||||
noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, attack: 0.002 });
|
noise(c, t, { type: "highpass", freq: 3000, dur: 0.035, gain: 0.02, attack: 0.005 });
|
||||||
},
|
},
|
||||||
fall: (c, t) => {
|
fall: (c, t) => {
|
||||||
[330, 247, 196, 147].forEach((f, i) => tone(c, t + i * 0.45, { freq: f, dur: 0.9, gain: 0.14, attack: 0.04, type: "triangle" }));
|
[262, 220, 196, 165].forEach((f, i) => tone(c, t + i * 0.6, { freq: f, dur: 1.9, gain: 0.09, attack: 0.16 }));
|
||||||
},
|
},
|
||||||
fanfare: (c, t) => {
|
fanfare: (c, t) => {
|
||||||
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.14, { freq: f, dur: 1.1, gain: 0.09, attack: 0.03 }));
|
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.2, { freq: f, dur: 1.9, gain: 0.07, attack: 0.07 }));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,7 +174,12 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
|||||||
c.resume?.().catch?.(() => {});
|
c.resume?.().catch?.(() => {});
|
||||||
return; // still locked: nothing to hear yet
|
return; // still locked: nothing to hear yet
|
||||||
}
|
}
|
||||||
|
const level = Math.max(0, Math.min(1, mix()[GROUP_OF[name]] ?? 1));
|
||||||
|
if (level < 0.005) return;
|
||||||
master.gain.value = Math.max(0, Math.min(1, volume()));
|
master.gain.value = Math.max(0, Math.min(1, volume()));
|
||||||
|
out = c.createGain();
|
||||||
|
out.gain.value = level;
|
||||||
|
out.connect(master);
|
||||||
try {
|
try {
|
||||||
make(c, c.currentTime + 0.005);
|
make(c, c.currentTime + 0.005);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -132,5 +187,5 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { play, unlock, names: Object.keys(SOUNDS) };
|
return { play, unlock, names: Object.keys(SOUNDS), groupOf: (name) => GROUP_OF[name] };
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-5
@@ -8,6 +8,7 @@ import { encodeSave, decodeSave, migrate } from "../save/codec.js";
|
|||||||
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
|
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
|
||||||
import { $, esc } from "./desk.js";
|
import { $, esc } from "./desk.js";
|
||||||
import { renderMarkdown } from "./markdown.js";
|
import { renderMarkdown } from "./markdown.js";
|
||||||
|
import { MIX_GROUPS } from "./audio.js";
|
||||||
|
|
||||||
const ARM_MS = 4000; // how long a "Sure?" button stays armed
|
const ARM_MS = 4000; // how long a "Sure?" button stays armed
|
||||||
|
|
||||||
@@ -221,9 +222,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
<input type="checkbox" id="setting-sound" ${s.sound ? "checked" : ""}>
|
<input type="checkbox" id="setting-sound" ${s.sound ? "checked" : ""}>
|
||||||
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
|
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
|
||||||
</label>
|
</label>
|
||||||
<label class="menu__field">${t("settings.volume")}
|
<div class="menu__list">${button("mixer", t("settings.mixer"))}</div>
|
||||||
<input type="range" id="setting-volume" min="0" max="1" step="0.05" value="${s.volume}" ${s.sound ? "" : "disabled"}>
|
|
||||||
</label>
|
|
||||||
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
|
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
|
||||||
${back()}`;
|
${back()}`;
|
||||||
}
|
}
|
||||||
@@ -253,7 +252,26 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
return `<div class="rules" tabindex="0" aria-label="${t("rules.title")}">${html}</div>${back()}`;
|
return `<div class="rules" tabindex="0" aria-label="${t("rules.title")}">${html}</div>${back()}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules };
|
// One slider per sound group, plus the overall volume and the music. Levels apply as you drag.
|
||||||
|
function mixer() {
|
||||||
|
const s = getSettings();
|
||||||
|
const rows = [["master", s.volume], ["music", s.mix.music], ...MIX_GROUPS.map((g) => [g, s.mix[g]])];
|
||||||
|
return `
|
||||||
|
<h3>${t("mixer.title")}</h3>
|
||||||
|
<p class="menu__sub">${t("mixer.hint")}</p>
|
||||||
|
<div class="mixer">
|
||||||
|
${rows.map(([key, v]) => `
|
||||||
|
<label class="mixer__row ${key === "master" ? "mixer__row--master" : ""}">
|
||||||
|
<span>${t("mixer." + key)}</span>
|
||||||
|
<input type="range" min="0" max="1" step="0.05" value="${v}" data-mix="${key}" aria-label="${t("mixer." + key)}">
|
||||||
|
<output>${Math.round(v * 100)}%</output>
|
||||||
|
</label>`).join("")}
|
||||||
|
</div>
|
||||||
|
<div class="menu__list">${button("mixerReset", t("mixer.reset"))}</div>
|
||||||
|
<footer class="menu__foot">${button("settings", t("menu.back"))}</footer>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules, mixer };
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
@@ -324,6 +342,8 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
import: () => go("import"),
|
import: () => go("import"),
|
||||||
settings: () => go("settings"),
|
settings: () => go("settings"),
|
||||||
rules: () => go("rules"),
|
rules: () => go("rules"),
|
||||||
|
mixer: () => go("mixer"),
|
||||||
|
mixerReset: () => { handlers.onSettings({ resetMix: true }); render(); },
|
||||||
credits: () => go("credits"),
|
credits: () => go("credits"),
|
||||||
back: () => go("home"),
|
back: () => go("home"),
|
||||||
resume: () => handlers.onResume(),
|
resume: () => handlers.onResume(),
|
||||||
@@ -362,10 +382,18 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
|
|||||||
ACTIONS[action](arg);
|
ACTIONS[action](arg);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
root.addEventListener("input", (e) => {
|
||||||
|
const key = e.target.dataset?.mix;
|
||||||
|
if (!key) return;
|
||||||
|
const v = Number(e.target.value);
|
||||||
|
e.target.parentElement.querySelector("output").textContent = `${Math.round(v * 100)}%`;
|
||||||
|
handlers.onSettings(key === "master" ? { volume: v } : { mix: { [key]: v } });
|
||||||
|
});
|
||||||
|
|
||||||
root.addEventListener("change", async (e) => {
|
root.addEventListener("change", async (e) => {
|
||||||
|
if (e.target.dataset?.mix) handlers.onPreview?.(e.target.dataset.mix);
|
||||||
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
|
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
|
||||||
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
|
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
|
||||||
if (e.target.id === "setting-volume") handlers.onSettings({ volume: Number(e.target.value) });
|
|
||||||
if (e.target.id === "import-file" && e.target.files[0]) {
|
if (e.target.id === "import-file" && e.target.files[0]) {
|
||||||
$(root, "#import-text").value = await e.target.files[0].text();
|
$(root, "#import-text").value = await e.target.files[0].text();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
// Background music: plays the .mp3 files of the music folder (listed by the server at music/index.json)
|
||||||
|
// in random order, like a shuffled tape. No DOM here: the walkman panel drives it and listens to it.
|
||||||
|
//
|
||||||
|
// States: "stopped" (nothing loaded), "paused" (track loaded, silent), "playing".
|
||||||
|
// Browsers refuse to start audio before the first click or key press; start() then waits for it.
|
||||||
|
|
||||||
|
export const prettyTitle = (file) => {
|
||||||
|
const base = file.replace(/\.mp3$/i, "");
|
||||||
|
return base.replace(/^\d+[\s._-]*/, "").replace(/_+/g, " ").replace(/\s+/g, " ").trim() || base;
|
||||||
|
};
|
||||||
|
|
||||||
|
function shuffled(list) {
|
||||||
|
const a = [...list];
|
||||||
|
for (let i = a.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[a[i], a[j]] = [a[j], a[i]];
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMusic({ getVolume = () => 0.3, onUserChoice = () => {} } = {}) {
|
||||||
|
const el = new Audio();
|
||||||
|
el.preload = "none";
|
||||||
|
|
||||||
|
let tracks = [];
|
||||||
|
let bag = []; // tracks still to come in this shuffle
|
||||||
|
let history = []; // tracks already played, for "previous"
|
||||||
|
let current = null;
|
||||||
|
let state = "stopped";
|
||||||
|
let waitingForGesture = false;
|
||||||
|
let failures = 0;
|
||||||
|
let listener = () => {};
|
||||||
|
|
||||||
|
const notify = () => listener(info());
|
||||||
|
const info = () => ({ available: tracks.length > 0, state, title: current ? prettyTitle(current) : "", waitingForGesture });
|
||||||
|
|
||||||
|
// The next track of the shuffle, never the one that just played (unless it is the only one).
|
||||||
|
function pick() {
|
||||||
|
if (!bag.length) {
|
||||||
|
bag = shuffled(tracks);
|
||||||
|
if (bag.length > 1 && bag[0] === current) bag.push(bag.shift());
|
||||||
|
}
|
||||||
|
return bag.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTrack(name) {
|
||||||
|
current = name;
|
||||||
|
el.src = `music/${encodeURIComponent(name)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVolume() {
|
||||||
|
el.volume = Math.max(0, Math.min(1, getVolume()));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function begin() {
|
||||||
|
applyVolume();
|
||||||
|
try {
|
||||||
|
await el.play();
|
||||||
|
state = "playing";
|
||||||
|
waitingForGesture = false;
|
||||||
|
} catch (err) {
|
||||||
|
// Blocked until the player touches the page, or the file could not be read.
|
||||||
|
if (err?.name === "NotAllowedError") waitingForGesture = true;
|
||||||
|
state = current ? "paused" : "stopped";
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
el.addEventListener("playing", () => { failures = 0; });
|
||||||
|
el.addEventListener("ended", () => next());
|
||||||
|
el.addEventListener("error", () => {
|
||||||
|
// A broken or unreadable file: try another tape, and give up when none plays.
|
||||||
|
failures++;
|
||||||
|
if (failures > tracks.length) {
|
||||||
|
state = "stopped";
|
||||||
|
notify();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (current) history.pop();
|
||||||
|
skip();
|
||||||
|
});
|
||||||
|
|
||||||
|
function skip() {
|
||||||
|
if (!tracks.length) return;
|
||||||
|
setTrack(pick());
|
||||||
|
void begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const r = await fetch("music/index.json", { cache: "no-store" });
|
||||||
|
const data = r.ok ? await r.json() : {};
|
||||||
|
tracks = Array.isArray(data.tracks) ? data.tracks.filter((n) => typeof n === "string") : [];
|
||||||
|
} catch {
|
||||||
|
tracks = [];
|
||||||
|
}
|
||||||
|
notify();
|
||||||
|
return tracks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function play() {
|
||||||
|
if (!tracks.length) return;
|
||||||
|
if (state === "paused" && current) {
|
||||||
|
void begin();
|
||||||
|
} else {
|
||||||
|
if (current) history.push(current);
|
||||||
|
skip();
|
||||||
|
}
|
||||||
|
onUserChoice(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pause() {
|
||||||
|
if (state !== "playing") return;
|
||||||
|
el.pause();
|
||||||
|
state = "paused";
|
||||||
|
waitingForGesture = false;
|
||||||
|
onUserChoice(false);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
el.pause();
|
||||||
|
el.currentTime = 0;
|
||||||
|
state = "stopped";
|
||||||
|
current = null;
|
||||||
|
waitingForGesture = false;
|
||||||
|
onUserChoice(false);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (!tracks.length) return;
|
||||||
|
if (current) history.push(current);
|
||||||
|
skip();
|
||||||
|
onUserChoice(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Back to the start of the track, or to the one before when it has barely begun.
|
||||||
|
function prev() {
|
||||||
|
if (!tracks.length) return;
|
||||||
|
if (state === "stopped" || el.currentTime > 3 || !history.length) {
|
||||||
|
if (current) el.currentTime = 0;
|
||||||
|
else skip();
|
||||||
|
} else {
|
||||||
|
setTrack(history.pop());
|
||||||
|
void begin();
|
||||||
|
}
|
||||||
|
onUserChoice(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called on the first click or key press: starts the music if the player wants it.
|
||||||
|
function unlockedByGesture(wanted) {
|
||||||
|
if (wanted && state === "stopped" && tracks.length) skip();
|
||||||
|
else if (waitingForGesture && current) void begin();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
load, play, pause, stop, next, prev, applyVolume, unlockedByGesture,
|
||||||
|
info,
|
||||||
|
setListener: (fn) => { listener = fn; fn(info()); },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// A small cassette player on the desk: a cassette window with two reels that turn while the music plays,
|
||||||
|
// the track title on the cassette label, and five vintage keys (previous, play, pause, stop, next).
|
||||||
|
|
||||||
|
import { t } from "../i18n/i18n.js";
|
||||||
|
import { $ } from "./desk.js";
|
||||||
|
|
||||||
|
const KEYS = [
|
||||||
|
["prev", `<path d="M12 1v14L3 8zM22 1v14l-9-7z"/>`],
|
||||||
|
["play", `<path d="M7 1v14l11-7z"/>`],
|
||||||
|
["pause", `<path d="M6 1h4v14H6zM14 1h4v14h-4z"/>`],
|
||||||
|
["stop", `<path d="M6 2h12v12H6z"/>`],
|
||||||
|
["next", `<path d="M2 1v14l9-7zM12 1v14l9-7z"/>`],
|
||||||
|
];
|
||||||
|
|
||||||
|
const reel = (side) => `
|
||||||
|
<svg class="walkman__reel walkman__reel--${side}" viewBox="0 0 40 40" aria-hidden="true" focusable="false">
|
||||||
|
<circle class="walkman__tape" cx="20" cy="20" r="17"/>
|
||||||
|
<g class="walkman__spokes">${[0, 60, 120].map((a) => `<line x1="20" y1="9" x2="20" y2="31" transform="rotate(${a} 20 20)"/>`).join("")}</g>
|
||||||
|
<circle class="walkman__hub" cx="20" cy="20" r="6"/>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
export function createWalkman(root, { music, onPress = () => {} }) {
|
||||||
|
root.innerHTML = `
|
||||||
|
<div class="walkman__body">
|
||||||
|
<div class="walkman__badge"><b>VALDOR</b><span>${t("walkman.model")}</span><i class="walkman__led" aria-hidden="true"></i></div>
|
||||||
|
<div class="walkman__window">
|
||||||
|
${reel("left")}
|
||||||
|
<div class="walkman__label"><span class="walkman__title" role="status"></span></div>
|
||||||
|
${reel("right")}
|
||||||
|
</div>
|
||||||
|
<div class="walkman__keys" role="group" aria-label="${t("walkman.title")}">
|
||||||
|
${KEYS.map(([act, icon]) => `
|
||||||
|
<button class="walkman__key walkman__key--${act}" data-act="${act}" aria-label="${t("walkman." + act)}" title="${t("walkman." + act)}">
|
||||||
|
<svg viewBox="0 0 24 16" aria-hidden="true" focusable="false">${icon}</svg>
|
||||||
|
</button>`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const title = $(root, ".walkman__title");
|
||||||
|
const label = $(root, ".walkman__label");
|
||||||
|
|
||||||
|
root.addEventListener("click", (e) => {
|
||||||
|
const b = e.target.closest("button[data-act]");
|
||||||
|
if (!b || b.disabled) return;
|
||||||
|
onPress();
|
||||||
|
music[b.dataset.act]();
|
||||||
|
});
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
const m = music.info();
|
||||||
|
const playing = m.state === "playing";
|
||||||
|
root.classList.toggle("is-playing", playing);
|
||||||
|
root.classList.toggle("is-empty", !m.available);
|
||||||
|
root.title = m.available ? "" : t("walkman.hint");
|
||||||
|
|
||||||
|
let text = t("walkman.empty");
|
||||||
|
if (m.available) text = m.waitingForGesture ? t("walkman.tap") : m.state === "stopped" ? t("walkman.stopped") : m.title;
|
||||||
|
if (title.textContent !== text) {
|
||||||
|
title.textContent = text;
|
||||||
|
title.classList.remove("is-scroll");
|
||||||
|
// a long title scrolls across the label while the tape runs
|
||||||
|
requestAnimationFrame(() => title.classList.toggle("is-scroll", title.scrollWidth > label.clientWidth + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const b of root.querySelectorAll("button[data-act]")) {
|
||||||
|
b.disabled = !m.available;
|
||||||
|
if (b.dataset.act === "play") b.setAttribute("aria-pressed", String(playing));
|
||||||
|
if (b.dataset.act === "pause") b.setAttribute("aria-pressed", String(m.state === "paused"));
|
||||||
|
if (b.dataset.act === "stop") b.setAttribute("aria-pressed", String(m.state === "stopped"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
music.setListener(update);
|
||||||
|
return { update };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user