feat: Adding audio player + music to play

This commit is contained in:
gauvainboiche
2026-10-25 22:35:53 +01:00
parent 7a525d084a
commit dd4714f499
39 changed files with 850 additions and 81 deletions
+1
View File
@@ -7,5 +7,6 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/game ./server
FROM gcr.io/distroless/static
COPY --from=build /out/game /game
ENV MUSIC_DIR=/music
EXPOSE 8080
ENTRYPOINT ["/game"]
+10 -5
View File
@@ -22,18 +22,23 @@ Without Docker (Go 1.23+):
go run ./server # PORT=3000 go run ./server to pick a port
```
There is nothing to configure: no database, no environment variables except the optional `PORT`. Saves live in the player's browser (`localStorage`), with three manual slots and export/import as text.
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) 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, sounds are synthesised with the Web Audio API, fonts are bundled (OFL).
- **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) and the content tests (*_test.go)
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
@@ -45,7 +50,7 @@ web/
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), audio, markdown
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/
+3
View File
@@ -4,6 +4,9 @@ services:
ports:
- "8080:8080"
restart: unless-stopped
volumes:
# background music: drop .mp3 files in ./music, no rebuild needed
- ./music:/music:ro
healthcheck:
# distroless has no shell or curl, so the binary probes itself
test: ["CMD", "/game", "-healthcheck"]
+3
View File
@@ -260,3 +260,6 @@ A weighted score over the indicators averaged across the whole term (not just fi
- 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.
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.
BIN
View File
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.
+10
View File
@@ -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

View File
+59 -3
View File
@@ -1,10 +1,14 @@
package main
import (
"encoding/json"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"countrygame/web"
)
@@ -24,19 +28,31 @@ func main() {
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, ".")
if err != nil {
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.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
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))))
log.Printf("country game listening on :%s", port)
log.Fatal(http.ListenAndServe(":"+port, mux))
return mux
}
// 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)
})
}
// 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})
}
}
+94
View File
@@ -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)
}
}
+21 -4
View File
@@ -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(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 });
saveSettings(store, { lang: "fr", slow: true, sound: false, volume: 0.25 });
assert.deepEqual(loadSettings(store), { lang: "fr", slow: true, sound: false, volume: 0.25 });
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, mix: { stamp: 0.3, paper: 2, music: 0.8 }, musicOn: false });
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" }));
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" }));
assert.equal(loadSettings(store).volume, 0.6, "an out-of-range volume falls back to the default");
@@ -496,6 +496,23 @@ assert.equal(E.advanceDay(s, bal).blocked, "over");
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
{
// the world cycle moves, stays within bounds and is part of the saved state
+2 -2
View File
@@ -104,8 +104,8 @@ body {
#side {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 22px;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 18px;
min-height: 0;
}
+17
View File
@@ -16,6 +16,8 @@
.cal__menu { display: block; width: 100%; margin-top: 8px; }
.menu__folder {
scrollbar-width: thin;
scrollbar-color: #a37a24 rgba(110, 75, 25, 0.25);
position: relative;
width: min(580px, 94%);
max-height: 100%;
@@ -147,3 +149,18 @@
.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 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
View File
@@ -24,21 +24,24 @@
#pad > * { grid-column: 1; }
/* 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 */
#dashboard, #folder, #ledger, #newspaper, #calendar { grid-row: 2; display: none; overflow-y: auto; }
#side { display: contents; }
#dashboard, #folder, #ledger { grid-row: 2; display: none; overflow-y: auto; }
#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="brief"] #dashboard,
#pad[data-view="news"] #newspaper,
#pad[data-view="clock"] #calendar { display: block; }
#calendar { align-self: start; }
#pad[data-view="brief"] #dashboard { display: block; }
#pad[data-view="clock"] #side,
#pad[data-view="news"] #side { display: grid; }
#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 */
#pad.is-ledger #folder { display: none; }
#pad[data-view="ledger"] #ledger:not([hidden]) { display: block; }
#pad.is-ledger #side { display: contents; }
#ledger { grid-column: 1; }
/* Menu and end screens cover the shelf too */
+115
View File
@@ -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; }
}
+6
View File
@@ -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 |
| 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
- Balance the books, but do not be shy: bold investment pays up to a point, then the debt calls.
+6
View File
@@ -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 |
| É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
- Équilibrez les comptes, mais sans timidité : l'investissement audacieux paie jusqu'à un certain point, puis la dette réclame son dû.
+2
View File
@@ -12,6 +12,7 @@
<link rel="stylesheet" href="css/decision.css">
<link rel="stylesheet" href="css/endgame.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/mobile.css">
</head>
@@ -27,6 +28,7 @@
<section id="menu" hidden aria-label="Menu"></section>
<div id="side">
<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>
</div>
</div>
+29 -1
View File
@@ -202,5 +202,33 @@
"rules.error": "The rule book could not be opened.",
"settings.sound": "Sounds",
"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
View File
@@ -202,5 +202,33 @@
"rules.error": "Le règlement n'a pas pu être ouvert.",
"settings.sound": "Sons",
"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
View File
@@ -26,6 +26,9 @@ import { createEndgame } from "./ui/endgame.js";
import { createMenu } from "./ui/menu.js";
import { createShelf } from "./ui/shelf.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 getJson = (url) => fetch(url).then((r) => r.json());
@@ -54,7 +57,23 @@ let settings = loadSettings(storage);
const browserLang = navigator.language?.startsWith("fr") ? "fr" : "en";
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 devRun = DEV_PARAMS.some((k) => params.has(k)) && !params.has("menu");
@@ -79,9 +98,11 @@ const el = {
news: document.getElementById("newspaper"),
menu: document.getElementById("menu"),
shelf: document.getElementById("shelf"),
walkman: document.getElementById("walkman"),
};
const pad = document.getElementById("pad");
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.
function prepareWeek() {
@@ -136,6 +157,7 @@ function build() {
ledger: createLedger(el.ledger, scenario, { onConfirm: () => { audio.play("sign"); render(); } }),
cal: createCalendar(el.cal, balance, { 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),
alerts: createTelegram(el.alerts, { alerts: endgame.alerts, onDismiss }),
endgame: createEndgame(el.endgame, { newsName: newsTpl.name, endgame, cards, onAgain: quitToMenu }),
@@ -218,7 +240,7 @@ function quitToMenu() {
panels?.decision.destroy();
state = 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");
el.ledger.hidden = true;
el.alerts.hidden = true;
@@ -231,9 +253,12 @@ function quitToMenu() {
function applySettings(patch) {
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);
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;
setLang(patch.lang);
document.title = t("app.title");
@@ -241,6 +266,12 @@ function applySettings(patch) {
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, {
content,
storage,
@@ -252,6 +283,7 @@ const menu = createMenu(el.menu, {
onResume: closeMenu,
onQuit: quitToMenu,
onSettings: applySettings,
onPreview: (key) => audio.play(PREVIEW[key] ?? "click"),
},
});
@@ -345,7 +377,7 @@ function step(now) {
if (!r.advanced) break;
advanced = true;
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 (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 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) {
step(now);
if (panels) {
const p = Math.min(1, clock.acc / msPerDay());
panels.cal.clockHand(p);
panels.shelf.clockHand(p);
countdownTicks(p);
}
requestAnimationFrame(frame);
}
+11 -3
View File
@@ -88,7 +88,13 @@ export function readSave(store, key) {
// ---- 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) {
try {
@@ -97,10 +103,12 @@ export function loadSettings(store) {
lang: s.lang === "en" || s.lang === "fr" ? s.lang : DEFAULT_SETTINGS.lang,
slow: s.slow === true,
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 {
return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, mix: { ...DEFAULT_MIX } };
}
}
+91 -36
View File
@@ -1,14 +1,25 @@
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft
// (slow attacks, low levels, filtered noise) so it stays in the background of the desk.
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft: slow
// 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.
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 master = null;
let noiseBuf = null;
let out = null; // where the sound being built sends its notes (a gain node per play)
function context() {
if (!ctx) {
if (ctx) return ctx;
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
if (!AC) return null;
try {
@@ -16,9 +27,37 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
} catch {
return null;
}
// master -> no deep bass -> no harsh treble -> gentle limiter -> speakers, plus a soft room send
master = ctx.createGain();
master.connect(ctx.destination);
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;
}
@@ -28,8 +67,8 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
}
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.
function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) {
// One note: a wave with an attack and an exponential fade.
function tone(c, at, { freq, to, type = "sine", dur = 0.3, gain = 0.1, attack = 0.02 }) {
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
@@ -38,13 +77,13 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(gain, at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
o.connect(g).connect(master);
o.connect(g).connect(out);
o.start(at);
o.stop(at + dur + 0.05);
}
// Filtered noise: paper, pages, thumps and clicks.
function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) {
// Filtered noise: paper, pages, soft impacts and ticks.
function noise(c, at, { dur = 0.2, gain = 0.06, type = "bandpass", freq = 2000, to, q = 0.6, attack = 0.03 }) {
noiseBuf ??= (() => {
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
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.linearRampToValueAtTime(gain, at + attack);
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.stop(at + dur + 0.05);
}
const SOUNDS = {
// A padded "thup" rather than a thud: mid-range, slow to start, with a soft paper tail.
stampYes: (c, t) => {
tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 });
noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 });
tone(c, t, { freq: 230, to: 130, dur: 0.32, gain: 0.2, attack: 0.016 });
noise(c, t, { type: "lowpass", freq: 1100, dur: 0.18, gain: 0.09, attack: 0.014, q: 0.4 });
noise(c, t + 0.02, { freq: 2000, to: 1400, dur: 0.32, gain: 0.025, attack: 0.03 });
},
stampNo: (c, t) => {
tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 });
noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 });
tone(c, t, { freq: 190, to: 108, dur: 0.34, gain: 0.2, attack: 0.016 });
noise(c, t, { type: "lowpass", freq: 900, dur: 0.2, gain: 0.09, attack: 0.014, q: 0.4 });
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) => {
noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 });
noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 });
noise(c, t, { freq: 450, to: 2800, dur: 0.52, gain: 0.085, attack: 0.13 });
noise(c, t + 0.4, { freq: 2200, dur: 0.18, gain: 0.035, attack: 0.03 });
},
sign: (c, t) => {
noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 });
tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 });
noise(c, t, { freq: 3200, to: 2400, dur: 0.42, gain: 0.04, q: 2, attack: 0.11 });
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) => {
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 });
tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 });
tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 });
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.44, { freq: 1320, dur: 1.6, gain: 0.07, attack: 0.04 });
tone(c, t + 0.44, { freq: 1980, dur: 1.1, gain: 0.022, attack: 0.04 });
},
alert: (c, t) => {
tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 });
tone(c, t + 0.24, { freq: 175, dur: 0.55, 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.32, { freq: 175, dur: 1.25, gain: 0.12, type: "triangle", attack: 0.16 });
},
chime: (c, t) => {
tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 });
tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 });
tone(c, t, { freq: 659, dur: 1.4, gain: 0.08, attack: 0.06 });
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) => {
tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 });
noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, 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.035, gain: 0.02, attack: 0.005 });
},
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) => {
[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?.(() => {});
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()));
out = c.createGain();
out.gain.value = level;
out.connect(master);
try {
make(c, c.currentTime + 0.005);
} 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
View File
@@ -8,6 +8,7 @@ import { encodeSave, decodeSave, migrate } from "../save/codec.js";
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
import { $, esc } from "./desk.js";
import { renderMarkdown } from "./markdown.js";
import { MIX_GROUPS } from "./audio.js";
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" : ""}>
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
</label>
<label class="menu__field">${t("settings.volume")}
<input type="range" id="setting-volume" min="0" max="1" step="0.05" value="${s.volume}" ${s.sound ? "" : "disabled"}>
</label>
<div class="menu__list">${button("mixer", t("settings.mixer"))}</div>
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
${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()}`;
}
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() {
root.innerHTML = `
@@ -324,6 +342,8 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
import: () => go("import"),
settings: () => go("settings"),
rules: () => go("rules"),
mixer: () => go("mixer"),
mixerReset: () => { handlers.onSettings({ resetMix: true }); render(); },
credits: () => go("credits"),
back: () => go("home"),
resume: () => handlers.onResume(),
@@ -362,10 +382,18 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
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) => {
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-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]) {
$(root, "#import-text").value = await e.target.files[0].text();
}
+162
View File
@@ -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()); },
};
}
+75
View File
@@ -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 };
}