Document markup and improve choice tags

This commit is contained in:
2026-05-17 15:52:41 +02:00
parent c2fb27b6b8
commit 2c54498ee2
52 changed files with 3485 additions and 377 deletions
+6 -1
View File
@@ -1,4 +1,9 @@
node_modules
node_modules
# windsurf rules
.windsurfrules
# local inspection / generated scratch artifacts
.tmp/
*.orig
*.bkp
+63
View File
@@ -26,6 +26,10 @@ The production client must tolerate TTS being unavailable. The safe default TTS
- Done: placeholder game API for new/load/save/running state.
- Done: sound effect and music folders, sound effect playback, music playback, and music ducking during TTS.
- Done: image markup is parsed, persisted in history, restored from save/history, and rendered as line-snapped page blocks.
- Done: Ink engine integration with source compilation, engine config, metadata handoff, choice-mode turns, one-list choice UI, and keyboard choice letters.
- Done: localized UI strings, game metadata language handoff for typography/hyphenation/TTS language, and German dialogue guillemet normalization.
- Done: localized popup queue for intended endings, unrecoverable errors, achievements, and tutorial/player alerts.
- Done: credits dialog and third-party license display.
- Partial: save-game API restores story state and Ink state, but the broader save/storage model still needs hardening for all engines.
- Pending: deeper automated tests for layout, playback timing, TTS provider switching, and media cue timing.
@@ -74,6 +78,7 @@ The loader is deliberately the conductor, not the orchestra. Module-specific con
- `options-ui-module.js`: options modal, persisted controls, provider status displays.
- `ui-controller-module.js`: top-bar commands, global input behavior, game API control wiring.
- `ui-display-handler-module.js`: book page display, startup prompt, unified live/history rendering, line-coordinate scrolling, image placement, and media block dispatch.
- `choice-display-module.js`: choice-mode rendering, keyboard-letter assignment, click/keyboard choice dispatch, and future choice-template hook.
- `ui-input-handler-module.js`: command entry, history, fast-forward key handling.
- `socket-client-module.js`: socket connection and game API request wrapper.
- `game-loop-module.js`: high-level client/game flow.
@@ -192,6 +197,16 @@ Canonical structural and media tags use Ink-style `#` syntax:
- YAML and Zork narrative output use the same leading `#...` syntax, parsed by the server into `StoryTag` objects before the client sees them.
- The browser protocol is structured `TurnResult` objects with structured tags and render blocks, not raw story markup.
Tag syntax:
```text
#key
#key[value]
#key[value](options)
```
The current parser accepts the bracket/parentheses form above and colon value tags such as `#key:x` or `#action:movement`.
Markdown emphasis:
```text
@@ -256,6 +271,47 @@ File names resolve relative to `public/music/`. Modes:
For chapter openings, authors can place `#music[file](..., lead=N)` after `#chapter[...]` and before the first prose paragraph. The heading is rendered/spoken first, then music starts and plays alone for the lead duration, then the dropcapped paragraph continues. Music and image pauses are playback gates only; they must not stop the queue from preparing upcoming TTS in the background.
Sound effect options:
```text
#sfx[church-bells.ogg](max=8 fade fade-duration=2)
#sfx[steam-whistle.ogg](fade-after=4 fade-duration=1.5)
```
Supported SFX options are `max=`, `duration=`, `max-duration=`, `limit=`, `stop-after=`, `fade-after=`, bare seconds such as `4s`, `mode=fade`, `mode=stop`, `fade`, `stop`, `cut`, and `fade-duration=`/`fade-time=`.
Choice tags:
```ink
* [Open the compartment door]
# letter[o]
# action[examine]
```
`#letter[x]`, `# letter[x]`, or `#key:x` reserves keyboard letter `X` for that choice. Explicit letters are assigned first; remaining visible choices receive `A` through `Z` in screen order, skipping reserved letters. The current UI supports up to 26 visible choices and renders them in one list. `#action[name]` or `#action:name` is stored as the choice category and reserved for later template routing.
Future choice-template metadata should keep the same bracket tag syntax if implemented:
```text
#action[movement]
#optional
#gated[noble]
#sort[last]
```
The older standalone `MARKUP_GUIDELINES.md` draft proposed colon tags, digit keys, grouping, shuffling, and parser-style reserved shortcuts. The active implementation deliberately does not use that syntax yet. Any future expansion should first extend the shared tag parser and `TurnResult.choices` contract so Ink, YAML, and Z-code all still emit the same structured choice objects.
Game-state and popup tags:
```text
#score[You reached the quiet ending.]
#error[The story ended unexpectedly.]
#achievement[First Steps]
#alert[Try examining objects before using them.]
```
`#score[...]` marks an intended ending and is surfaced as `gameState.endState.type = intended` when the turn ends. `#error[...]` marks an unrecoverable ending and is surfaced as `gameState.endState.type = error`. The Ink engine emits a synthetic `#error[...]` when it runs out of content without an explicit `#score[...]`/`#error[...]`. `#achievement[...]` and `#alert[...]` show queued localized popups while play continues.
## TTS And Playback Specification
The playback system must keep text animation and audio synchronized.
@@ -345,6 +401,7 @@ Current placeholder behavior:
- `loadGame(slot)` requires a placeholder save and then starts the demo game like `newGame()`.
- Saves do not persist across reloads yet.
- `isGameRunning()` returns true after a game starts and until the session ends.
- Turn results use `inputMode: 'end'` when no more input is accepted. End turns may include `gameState.endState` and/or `#score[...]`/`#error[...]` tags to distinguish intended endings from unrecoverable errors.
UI requirements before a game starts:
@@ -415,6 +472,11 @@ Longer-term goal:
- [x] Added command input focus and global typing behavior.
- [x] Added command mirroring through the server path for UI testing.
- [x] Added fast-forward for animation and TTS fade/stop.
- [x] Added Ink source compilation and Ink engine server.
- [x] Added choice-mode UI and keyboard letter assignment from `#letter[x]` and `#key:x`.
- [x] Added localized ending, error, achievement, and alert popups from tag-channel events.
- [x] Added credits/license dialog.
- [x] Added line-addressed history scrolling model and moved the NOTE.md scrolling specification here.
### In Progress
@@ -422,6 +484,7 @@ Longer-term goal:
- [ ] Tighten automated checks around top-bar/options state initialization after reload.
- [ ] Improve automated visual regression coverage for page scaling, dropcap line-height alignment, and paragraph indentation.
- [ ] Improve automated audio tests for music ducking, sound effect timing, and fast-forward fadeout.
- [ ] Polish custom scrollbar dragging so the thumb moves freely during drag and commits the scroll target only on release.
### Pending
+81
View File
@@ -0,0 +1,81 @@
# Markup Guidelines
This file documents author-facing Ink tag conventions. The active parser normalizes tags into structured `StoryTag` objects before they reach the UI.
## Implemented Tag Forms
Use bracket tags for titles, filenames, and longer text:
```ink
#chapter[Eibenreith]
#image[statue.png](square)
#music[Kaiserpunk Waltz.mp3](crossfade, loop, lead=8)
#sfx[church-bells.ogg](max=8, fade)
#score[You reached an ending.]
#achievement[First Steps]
#alert[Try examining the room.]
```
Use colon tags for short identifiers, categories, and choice keys:
```ink
#action:movement
#key:l
#sort:last
#gated:noble
```
Bare flags are accepted as tags with no value:
```ink
#optional
```
## Choice Metadata
Choice tags are placed on the Ink choice they belong to:
```ink
* [__Schaue__: Aus dem Fenster.]
#action:orientation
#key:l
```
Implemented choice metadata:
- `#key:x`: reserves keyboard key `X` for the choice.
- `#letter[x]`: older equivalent for reserving keyboard key `X`.
- `#action:group` or `#action[group]`: stores a category/template hint.
The current UI renders all choices in one list. Explicit keys are assigned first; choices without explicit keys receive `A` through `Z` in visible order. Digits, grouping columns, stable shuffling, `#optional`, `#gated[...]`, and `#sort[...]` are documented authoring conventions or future metadata, not fully implemented UI behavior yet.
## Popup And End-State Tags
These tags may appear as Ink global tags, paragraph tags, or empty tag-only lines. They are dispatched through the same tag channel as media tags.
```ink
#score[You reached the quiet ending.]
#error[The story ended unexpectedly.]
#achievement[First Steps]
#alert[Try examining objects before using them.]
```
- `#score[...]`: intended ending. When the turn reaches `inputMode: end`, the UI shows a localized ending popup with the tag value as the optional message.
- `#error[...]`: unrecoverable ending. The UI shows a localized error popup with the tag value as the optional message. The Ink engine emits this automatically if Ink runs out of content without an explicit `#score[...]` or `#error[...]`.
- `#achievement[...]`: queued localized achievement popup while the game continues.
- `#alert[...]`: queued localized player hint/tutorial popup while the game continues.
## Existing Media And Structure Tags
```ink
#chapter[Title]
#section
#textblock
#image[filename.png](landscape)
#image[filename.png](portrait pause=2)
#image[filename.png](square lead=1.5)
#music[track.mp3](crossfade, loop, lead=4)
#sfx[file.ogg](max=8 fade fade-duration=2)
```
Asset filenames resolve relative to the configured image, music, and sound folders.
-56
View File
@@ -1,56 +0,0 @@
Now let's fix the history scrolling:
1.) Make sure <div id="page_right"> does not scroll! This should have no scrollbar or scrolling css whatsoever. It's fixed size. The #story_scrollbar should have a fixed height of 100% of #page_right and be placed absolutely over the parent, so it cannot be influenced by anything next or below it and it does not let clicks bubble through it to anything else below (no unpausing story on scrollbar usage). Neither #story or anything inside it should have a scrollbar!
2.) Every text or image block that is inserted into the dom should update it's entry in the historyDB to include it's height in lines. Every time this happens the height is added to a game engine value that stores the length of the game history in lines. The #page_rigiht is considered to have a fixed height in lines (calculate id). The #story_scrollbar is calculated using the position and size in lines of the current page view to the game history. For this calculation the history ends at the latestRenderedBlockId, not the latestBlockId.
3.) When a new block is put into the dom, the #story div is moved up (dynamically with an ease in/ease out animation) within the #page_rigt div until its bottom edge aligns with the bottom edge of it's parent (that's scrolling to the bottom). Any time this position changes the scrollbar is updated accordingly.
4.) Scrolling using the arrow up or arrow down keys or the mousewheel moves the pane smoothly up and down until it's end. There should be a value storing the block currently shown in the center of the page. The system dynamically adds and removes blocks to the top and bottom of the #paragraphs list so that there are 20 blocks above and 20 blocks below the central block (if available) at all times.
5.) Any kind of manual scrolling, be it via mousewheel, arrow keys up/down, or using the scrollbar immediately disables autoplay.
6.) Inserting images should first insert the image covered, then scroll down, and only once the scrolling animation finished start the revealing fade in of the image which should take 2 seconds.
Scrolling using the arrow keys or mousewheel works fine now, But I found the following bugs already:
1.) Autoplay does not disable on scrolling.
2.) Restoring the chapter beginning shows the chapter heading as a normal line of text and no longer produces the drop-cap.
3.) Loading a game that was saved while choices were present does not restore the choice dialog.
4.) The game now seems to always scroll to the center of the page to add/animate in new content. Please keep this position at the page bottom as it was, only adding as much space as needed for the new content (like before).
5.) Make sure autoscrolling or manual scrolling always stops at the nearest line boundary, so no cut of lines can be visible.
6.) Suggest a plan how to fix the scrollbar controls. They look correct now. But moving them with the mouse feels clunky and somethimes it triggers strange behavior. We need a solution where the bar can be moved freele up and down without doing anything yet. Once released the content between the current position and the target position including the necessary margins are loaded before the scrolling is animated and then the now unneccessary blocks unloaded.
That sounds still too complicated! Why invalidate the layout index?
Assume the following:
1.) The #right_page div has a size relative to the window. There is ONE line height value, which is a divisor or the page height/the page height has a fixed number of lines: Line height = Page height/fixed number of lines.
2.) All content has an exact multiple of line height as height all margins and paddings included.
3.) Therefore any coordinates or pixel sizes of the virtual content pane can be derived mathematically from line coordinates.
4.) Scrolling means translating the content vertically (with ease in/eas out animation) to the closest position where the page edges aligns with line edges.
5.) Since stored content does not change line numbers after creation, and (visible) content is never added in between existing blocks, updating the cumulative values should be unnecessary.
6.) Scrolling to the bottom means to scroll to the position where the bottom edge of the last line of the last element aligns with the bottom edge of the page.
7.) Scrolling to the top means to scroll to the position where the top edge of the first line of the first block aligns with the top edge of the page.
8.) Scrolling to the bottom to insert new content means the same as 6. but with the new content already added invisibly to the block history (advancing the current block counter), scrolling to the position and only then activating the fade in animation.
9.) In the case of portrait format images next to text the cumulative line positions can overlap but still should border on the line edges.
10.) Scrolling to a random position means to first load all content between the starting point and the target point + additional blocks in the movement direction into the right_page. Then scrolling so the bottom edge of the requested line aligns with the bottom edge of the page, if there is enough content above it otherwise it's the same as 7.) scrolling to the top. After the scroll has finished blocks a certain distance from the reached position are unloded.
Looks like you partially work with outdated specs: Here the last version:
1.) The #right_page div has a size relative to the window. There is ONE line height value, which is a divisor or the page height/the page height has a fixed number of lines: Line height = Page height/fixed number of lines.
2.) All content has an exact multiple of line height as height all margins and paddings included.
3.) Therefore any coordinates or pixel sizes of the virtual content pane can be derived mathematically from line coordinates.
4.) Scrolling means translating the content vertically (with ease in/eas out animation) to the closest position where the page edges aligns with line edges.
5.) Since stored content does not change line numbers after creation, and (visible) content is never added in between existing blocks, updating the cumulative values should be unnecessary.
6.) Scrolling to the bottom means to scroll to the position where the bottom edge of the last line of the last element aligns with the bottom edge of the page.
7.) Scrolling to the top means to scroll to the position where the top edge of the first line of the first block aligns with the top edge of the page.
8.) Scrolling to the bottom to insert new content means the same as 6. but with the new content already added invisibly to the block history (advancing the current block counter), scrolling to the position and only then activating the fade in animation.
9.) In the case of portrait format images next to text the cumulative line positions can overlap but still should border on the line edges.
10.) Scrolling to a random position means to first load all content between the starting point and the target point + additional blocks in the movement direction into the right_page. Then scrolling so the bottom edge of the requested line aligns with the bottom edge of the page, if there is enough content above it otherwise it's the same as 7.) scrolling to the top. After the scroll has finished blocks a certain distance from the reached position are unloded.
Put that wherever you keep your project specs but refine it with the following information:
DO NOT do pagination. The 41 blocks mean there should be one line that is the current position. If there is enough lines content before it this position is always asumed to be the last line of the page (line 25). Whichever block this line belongs to is the active block. The system should always keep 20 blocks before the active block, the active block, and 20 blocks after the active block loaded. That's where the 41 blocks come from. The moment scrolling shifts the active line into a new block in any direction one other block is to be loaded (in that direction) and one is to be unloaded (opposite this direction). If the coordinate is not reached by traversal but jumped to 20 blocks before the active block, the active block, all blocks between the starting block and the target block, the target block and 20 blocks after the target block should be loaded so the whole range can be traversed. If this exceeds a sensible amount of blocks in the dom, let's say 150 blocsk in total, all content on the page is faded out and unloaded, then the target block and 20 blocks before and after it (as available) are loaded before the content of the page is faded in again.
Please give me feedback whether you understand how I imagine this to work, If you agree this is feasable and then apply it to whatever your notes you keep about the project specifications.
What works and what doesnt:
1.) up arrow and down arrow perfectly scrolls up and down line by line, but pressing the button again while it is still scrolling leads to stuttering movement. Also all formats and layouts seem to be correct.
2,) Using the mousewheel or page up and down does move the content in the right direction but seemingly a random number of lines (about 1-5) not 24 or whatever the mousewheel speed says. Find out why? How is wheel speed translated into a scroll command?
3.) Home loads a completely new page which takes some time and then flickers into existence ... then it correctly scrolls to the top.
4.) End removes all content from the page, takes some time then you can see the scroll bar go to the bottom, but content does not re-appear before or after the scroll. Only manually scrolling using the arrow key shows the content again.
5.) Scrolling correctly cancels playback and animation. Resuming correctly scrolls to the bottom. Sometimes it resumes play as intended, but under certain conditions new TTS audio is played, but no new text is added. The game just always scrolls down to the last visible block and stays there, while the story and audio continues.
Do not fix it yet. Explain to me step by step why? Do not guess. Do not invent an explanation! Trace the what the program actually does from triggering event to end state and explain to me why this should work, cannot work, is complete or was left in an unfinished state!
+46 -4
View File
@@ -20,6 +20,9 @@ The web app starts on `http://localhost:3000` when launched through `src/index.t
```powershell
npm run dev # Start the web UI through ts-node/nodemon
npm run dev:ink # Start the Ink engine server, watch ink source, compile on restart
npm run dev:yaml # Start the YAML engine server
npm run dev:zork # Start the Z-code/Zork engine server
npm run start # Run the compiled web server from dist/
npm run build # Compile TypeScript
npm run test # Run Jest tests
@@ -28,6 +31,8 @@ npm run start:cli # Run the CLI interface
npm run dev:cli # Run the CLI interface through ts-node/nodemon
```
Each game engine also has an inspect command for debugger work: `npm run dev:ink:inspect`, `npm run dev:yaml:inspect`, and `npm run dev:zork:inspect`.
## Configuration
Environment variables are loaded from `.env`.
@@ -67,6 +72,7 @@ Major modules:
- `tts-factory-module.js` plus provider modules: TTS provider selection, voice settings, speed mapping, caching, and playback.
- `audio-manager-module.js`: master, speech, music, and sound effect volume, music playback, sound effects, and music ducking.
- `ui-controller-module.js`, `ui-display-handler-module.js`, `ui-input-handler-module.js`, `options-ui-module.js`: book UI, command input, options, top-bar controls, and game API calls.
- `choice-display-module.js`: choice-mode UI, click selection, keyboard-letter assignment, and future choice-template routing.
The static server sends no-cache headers for local development so stale ES modules do not mask changes. If the browser console shows `onpage-dialog.preload.js:121 Uncaught ReferenceError: browser is not defined`, ignore it; that comes from the installed ad blocker, not this project.
@@ -82,7 +88,18 @@ Inline Markdown emphasis:
***bold italic*** or ___bold italic___
```
Canonical block/media tags use Ink-style `#` syntax. In Ink these are real Ink tags. In YAML and Zork narrative output, leading `#...` lines are parsed by the server into the same structured `StoryTag` objects before reaching the client. The browser only consumes structured `TurnResult` objects.
Canonical block/media/control tags use Ink-style `#` syntax. In Ink these are real Ink tags. In YAML and Zork narrative output, leading `#...` lines are parsed by the server into the same structured `StoryTag` objects before reaching the client. The browser only consumes structured `TurnResult` objects.
Tag format:
```text
#key
#key[value]
#key[value](options)
#key:value
```
For Ink choices, put choice-local tags under the choice they belong to. Explicit keyboard letters are supported with `# letter[x]`, `#letter[x]`, or the colon form `#key:x`; the client reserves those letters first, then assigns the remaining visible choices from `A` through `Z` in order. `# action[name]` or `#action:name` is parsed as a category/template hint for future choice layouts, although the current UI displays all choices in one list.
Chapter:
@@ -108,23 +125,25 @@ The following paragraph returns to the normal indent.
`#textblock` is treated the same way. The first paragraph after the marker is separated from previous content by one line of vertical space.
Images are parsed for future rendering:
Images are story blocks:
```text
#image[mansion-rain.jpg](landscape)
#image[portrait-letter.jpg](portrait pause=2)
#image[seal.png](square lead=1.5)
```
Image file names are relative to `public/images/`. `widescreen` is accepted as an alias for `landscape`.
Image file names are relative to `public/images/`. `landscape`/`widescreen` and `square` images are centered, near full page width, and line-snapped. `portrait` images sit beside prose at half page width. Image pauses (`pause=`, `delay=`, `lead=`, or a bare `2s`) are skippable and do not block background TTS preparation.
Sound effects are story tags:
```text
#sfx[squeaky-door.ogg]
#sfx[church-bells.ogg](max=8 fade fade-duration=2)
The door opens and the hall exhales.
```
The tag is parsed by the server into a `StoryTag` object. Sound effect paths are relative to `public/sounds/`.
The tag is parsed by the server into a `StoryTag` object. Sound effect paths are relative to `public/sounds/`. Optional parameters can limit playback (`max=`, `duration=`, `stop-after=`, `fade-after=`), choose the end mode (`fade` or `stop`/`cut`), and set `fade-duration=`.
Music can be placed as a block:
@@ -134,6 +153,17 @@ Music can be placed as a block:
Music paths are relative to `public/music/`. Supported modes are `queue`, `crossfade`, and `cut`. Use `loop` or `once` to control repetition. `lead=<seconds>` delays the following text/TTS paragraph so the music can play alone before narration continues. To place that pause between a chapter heading and the dropcapped first paragraph, put the music tag after the chapter tag and before the first prose paragraph; TTS generation for the next spoken paragraph continues during the lead pause.
Game-state and player-message tags:
```text
#score[You found the quiet ending.]
#error[Ink story ended without an explicit ending tag.]
#achievement[First Steps]
#alert[Try examining objects before using them.]
```
`#score[...]` marks an intended ending and opens a localized ending popup when the turn reaches `inputMode: end`. `#error[...]` marks an unrecoverable ending and opens an error popup. If an Ink story runs out of content without an explicit `#score[...]` or `#error[...]`, the Ink engine emits an `#error[...]` tag. `#achievement[...]` and `#alert[...]` open localized queued popups while the game continues.
## Assets
- `public/sounds/`: sound effects referenced by `#sfx[file]` tags.
@@ -160,8 +190,20 @@ When real TTS audio is available, animation duration is driven by measured audio
Fast-forwarding by page click or space completes the active animation and fades/stops current TTS playback so queued content can proceed.
The right page history is line-addressed rather than natively scrolled. The page has a fixed line count, all block heights snap to whole lines, and the custom scrollbar represents virtual history line position. The DOM keeps a moving window of history blocks around the active line instead of paginating the story.
## Changelog
### 2026-05-17
- Added Ink engine support with source compilation, engine config files, game metadata, locale-driven UI text, choice mode, keyboard choice letters, and one-list choice rendering.
- Added line-addressed right-page history, save/load reconstruction, image restoration, custom scrollbar plumbing, and virtual block-window rendering.
- Added story image rendering for landscape, portrait, and square images, including line-snapped sizing and portrait text exclusion.
- Added localized popups for endings, errors, achievements, and alerts through the tag channel.
- Added credits and third-party license UI.
- Added per-volume mute toggles and configurable music ducking amount.
- Added German typography handling for dialogue guillemets based on game metadata language.
### 2026-05-14
- Consolidated usage, markup, and architecture documentation into `README.md` and `CLIENT_TODO.md`.
+47
View File
@@ -0,0 +1,47 @@
# Third-Party Library Audit
Date: 2026-05-17
## Summary
The project currently uses the expected browser-side typography/story libraries plus additional runtime packages:
- inkjs
- SmartyPants.js
- Hyphenopoly
- Knuth-Plass line breaking support (`knuth-and-plass.js`, `linebreak.js`, `linked-list.js`)
- Kokoro JS browser bundle
- Server/runtime npm packages: Express, Socket.IO, OpenAI SDK, Axios, cors, dotenv, js-yaml, ifvms
- EB Garamond font files
## Browser-vendored files
| Component | Files | Upstream/latest check | Local status |
| --- | --- | --- | --- |
| SmartyPants.js | `public/js/smartypants.js` | Local header says `smartypants.js 0.0.6`; npm `smartypants` latest is `0.2.2`. The old `smartypants.js` package name is unpublished from npm. | Not byte-identical to npm `smartypants` 0.0.5, 0.0.9, or 0.2.2. Treat as modified/older vendor code. |
| Hyphenopoly browser files | `public/js/Hyphenopoly.js`, `public/js/Hyphenopoly_Loader.js`, `public/js/hyphenopoly.module.js`, `public/js/patterns/*.wasm` | Browser header says `5.2.0-beta.1`; npm dependency is `6.0.0`; npm latest is `6.1.0`. | `Hyphenopoly.js` is effectively 5.2.0-beta.1 after line-ending normalization. `Hyphenopoly_Loader.js` has a small local/prototype difference in `H.hide`. Browser copy is older than package/latest. |
| Knuth-Plass adapter | `public/js/knuth-and-plass.js` | No authoritative upstream identified from headers or npm metadata. | Modified from the prototype copy and currently application-owned adapter code. |
| Line breaking support | `public/js/linebreak.js`, `public/js/linked-list.js` | No authoritative upstream identified from headers. Not the npm `linebreak` package 1.1.0. | Identical to prototype copies. `linked-list.js` still has a suspicious `get last() { return this.last; }` accessor inherited from the prototype. |
| Kokoro JS browser bundle | `public/js/kokoro-js.js` | npm `kokoro-js` latest is `1.2.1`; installed is `1.2.0`. | Byte-identical to `kokoro-js@1.2.0/dist/kokoro.web.js`; not latest. |
## Direct runtime npm packages
| Package | Installed | Latest checked | License | Status |
| --- | --- | --- | --- | --- |
| `inkjs` | 2.4.0 | 2.4.0 | MIT | Current. |
| `hyphenopoly` | 6.0.0 | 6.1.0 | MIT | Not latest. Browser vendored files are older than this dependency. |
| `kokoro-js` | 1.2.0 | 1.2.1 | Apache-2.0 | Not latest. |
| `ifvms` | 1.1.6 | 1.1.6 | MIT | Current. |
| `openai` | 4.91.0 | 6.38.0 | Apache-2.0 | Not latest major. |
| `socket.io` | 4.8.1 | 4.8.3 | MIT | Not latest patch. |
| `express` | 5.1.0 | 5.2.1 | MIT | Not latest patch. |
| `axios` | 1.8.4 | 1.16.1 | MIT | Not latest. |
| `cors` | 2.8.5 | 2.8.6 | MIT | Not latest patch. |
| `dotenv` | 16.4.7 | 17.4.2 | BSD-2-Clause | Not latest major. |
| `js-yaml` | 4.1.0 | 4.1.1 | MIT | Not latest patch. |
## Notices
The UI-readable license and credit notice is `public/THIRD_PARTY_NOTICES.md`.
The root `THIRD_PARTY_NOTICES.md` points to that served file so the repository has an obvious project-level notice entry.
+7
View File
@@ -0,0 +1,7 @@
# Third-Party Notices
The browser-visible third-party notices and license text live at:
`public/THIRD_PARTY_NOTICES.md`
That file is served by the game UI and is the source used by the in-game credits dialog.
+8 -7
View File
@@ -1,10 +1,10 @@
{
"engine": "ink",
"locale": "en_US",
"locale": "de_DE",
"paths": {
"mainGameFile": "data/ink/kaiserpunk.ink.json",
"inkSource": "data/ink-src/kaiserpunk.ink",
"inkCompiled": "data/ink/kaiserpunk.ink.json",
"mainGameFile": "data/ink/eibenreith.ink.json",
"inkSource": "data/ink-src/eibenreith.ink",
"inkCompiled": "data/ink/eibenreith.ink.json",
"music": "public/music",
"sfx": "public/sounds",
"images": "public/images"
@@ -12,8 +12,9 @@
"metadata": {
"title": "Eibenreith",
"author": "Georg Tomitsch",
"subtitle": "A Kaiserpunk Adventure",
"version": "0.0.1",
"copyright": "2026 by Bad Tools Studio"
"subtitle": "Ein Kaiserpunk Abenteuer",
"version": "0.0.2",
"language": "de_DE",
"copyright": "© 2026 Bad Tools Studio"
}
}
+1
View File
@@ -12,6 +12,7 @@
"author": "AI Interactive Fiction",
"subtitle": "An open-world text adventure",
"version": "1.0.0",
"language": "en_US",
"copyright": "Prototype content for local development."
}
}
+1
View File
@@ -13,6 +13,7 @@
"author": "Infocom",
"subtitle": "A narrated Z-code adventure",
"version": "1.0.0",
"language": "en_US",
"copyright": "Use only with a legally supplied Z-code story file."
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1
View File
@@ -5,6 +5,7 @@ export interface GameMetadata {
subtitle?: string;
version?: string;
copyright?: string;
language?: string;
}
export interface GamePaths {
mainGameFile: string;
+2
View File
@@ -30,6 +30,7 @@ function fallbackConfig(engine) {
subtitle: 'An open-world text adventure',
version: '1.0.0',
copyright: '',
language: 'en_US',
},
};
}
@@ -56,6 +57,7 @@ function loadGameConfig(configPath, engine) {
metadata: {
...fallback.metadata,
...(parsed.metadata ?? {}),
language: parsed.metadata?.language ?? parsed.locale ?? fallback.metadata.language,
},
};
}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":";;;;;AA0DA,kCAIC;AAED,wCAqBC;AAED,4EAkBC;AAED,4CAYC;AAvHD,gDAAwB;AACxB,2BAAyD;AA8BzD,MAAM,YAAY,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAEtD,SAAS,cAAc,CAAC,MAAkB;IACxC,OAAO;QACL,MAAM;QACN,MAAM,EAAE,OAAO;QACf,KAAK,EAAE;YACL,YAAY,EACV,MAAM,KAAK,KAAK;gBACd,CAAC,CAAC,yBAAyB;gBAC3B,CAAC,CAAC,MAAM,KAAK,MAAM;oBACjB,CAAC,CAAC,uBAAuB;oBACzB,CAAC,CAAC,+BAA+B;YACvC,KAAK,EAAE,cAAc;YACrB,GAAG,EAAE,eAAe;YACpB,MAAM,EAAE,eAAe;SACxB;QACD,QAAQ,EAAE;YACR,KAAK,EAAE,wBAAwB;YAC/B,MAAM,EAAE,eAAe;YACvB,QAAQ,EAAE,8BAA8B;YACxC,OAAO,EAAE,OAAO;YAChB,SAAS,EAAE,EAAE;SACd;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,WAAW,CAAC,sBAA8B;IACxD,OAAO,cAAI,CAAC,UAAU,CAAC,sBAAsB,CAAC;QAC5C,CAAC,CAAC,sBAAsB;QACxB,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,YAAY,EAAE,sBAAsB,CAAC,CAAC;AACzD,CAAC;AAED,SAAgB,cAAc,CAAC,UAAkB,EAAE,MAAkB;IACnE,MAAM,YAAY,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;QAC9B,OAAO,CAAC,IAAI,CAAC,oBAAoB,YAAY,WAAW,MAAM,YAAY,CAAC,CAAC;QAC5E,OAAO,cAAc,CAAC,MAAM,CAAC,CAAC;IAChC,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAA,iBAAY,EAAC,YAAY,EAAE,MAAM,CAAC,CAA8B,CAAC;IAC3F,MAAM,QAAQ,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,KAAK,EAAE;YACL,GAAG,QAAQ,CAAC,KAAK;YACjB,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;SACxB;QACD,QAAQ,EAAE;YACR,GAAG,QAAQ,CAAC,QAAQ;YACpB,GAAG,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;SAC3B;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,gCAAgC,CAAC,MAAwB;IACvE,MAAM,WAAW,GAAG;QAClB,MAAM,CAAC,KAAK,CAAC,KAAK;QAClB,MAAM,CAAC,KAAK,CAAC,GAAG;QAChB,MAAM,CAAC,KAAK,CAAC,MAAM;QACnB,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;QACzE,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;QAC7E,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS;QAC/E,MAAM,CAAC,KAAK,CAAC,SAAS;KACvB,CAAC;IAEF,KAAK,MAAM,SAAS,IAAI,WAAW,EAAE,CAAC;QACpC,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,MAAM,YAAY,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;QAC5C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;YAC9B,IAAA,cAAS,EAAC,YAAY,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAgB,gBAAgB,CAAC,MAAwB;IACvD,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,MAAM,EAAE;YACN,KAAK,EAAE,SAAS;YAChB,GAAG,EAAE,UAAU;YACf,MAAM,EAAE,UAAU;YAClB,MAAM,EAAE,UAAU;SACnB;KACF,CAAC;AACJ,CAAC"}
{"version":3,"file":"game-config.js","sourceRoot":"","sources":["../../src/config/game-config.ts"],"names":[],"mappings":";;;;;AA4DA,kCAIC;AAED,wCAsBC;AAED,4EAkBC;AAED,4CAYC;AA1HD,gDAAwB;AACxB,2BAAyD;AA+BzD,MAAM,YAAY,GAAG,cAAI,CAAC,OAAO,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;AAEtD,SAAS,cAAc,CAAC,MAAkB;IACxC,OAAO;QACL,MAAM;QACN,MAAM,EAAE,OAAO;QACf,KAAK,EAAE;YACL,YAAY,EACV,MAAM,KAAK,KAAK;gBACd,CAAC,CAAC,yBAAyB;gBAC3B,CAAC,CAAC,MAAM,KAAK,MAAM;oBACjB,CAAC,CAAC,uBAAuB;oBACzB,CAAC,CAAC,+BAA+B;YACvC,KAAK,EAAE,cAAc;YACrB,GAAG,EAAE,eAAe;YACpB,MAAM,EAAE,eAAe;SACxB;QACD,QAAQ,EAAE;YACR,KAAK,EAAE,wBAAwB;YAC/B,MAAM,EAAE,eAAe;YACvB,QAAQ,EAAE,8BAA8B;YACxC,OAAO,EAAE,OAAO;YAChB,SAAS,EAAE,EAAE;YACb,QAAQ,EAAE,OAAO;SAClB;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,WAAW,CAAC,sBAA8B;IACxD,OAAO,cAAI,CAAC,UAAU,CAAC,sBAAsB,CAAC;QAC5C,CAAC,CAAC,sBAAsB;QACxB,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,YAAY,EAAE,sBAAsB,CAAC,CAAC;AACzD,CAAC;AAED,SAAgB,cAAc,CAAC,UAAkB,EAAE,MAAkB;IACnE,MAAM,YAAY,GAAG,WAAW,CAAC,UAAU,CAAC,CAAC;IAC7C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;QAC9B,OAAO,CAAC,IAAI,CAAC,oBAAoB,YAAY,WAAW,MAAM,YAAY,CAAC,CAAC;QAC5E,OAAO,cAAc,CAAC,MAAM,CAAC,CAAC;IAChC,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,IAAA,iBAAY,EAAC,YAAY,EAAE,MAAM,CAAC,CAA8B,CAAC;IAC3F,MAAM,QAAQ,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACxC,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,MAAM,EAAE,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,MAAM;QACxC,KAAK,EAAE;YACL,GAAG,QAAQ,CAAC,KAAK;YACjB,GAAG,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAAC;SACxB;QACD,QAAQ,EAAE;YACR,GAAG,QAAQ,CAAC,QAAQ;YACpB,GAAG,CAAC,MAAM,CAAC,QAAQ,IAAI,EAAE,CAAC;YAC1B,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,QAAQ,IAAI,MAAM,CAAC,MAAM,IAAI,QAAQ,CAAC,QAAQ,CAAC,QAAQ;SACnF;KACF,CAAC;AACJ,CAAC;AAED,SAAgB,gCAAgC,CAAC,MAAwB;IACvE,MAAM,WAAW,GAAG;QAClB,MAAM,CAAC,KAAK,CAAC,KAAK;QAClB,MAAM,CAAC,KAAK,CAAC,GAAG;QAChB,MAAM,CAAC,KAAK,CAAC,MAAM;QACnB,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS;QACzE,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,CAAC,CAAC,SAAS;QAC7E,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,cAAI,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,SAAS;QAC/E,MAAM,CAAC,KAAK,CAAC,SAAS;KACvB,CAAC;IAEF,KAAK,MAAM,SAAS,IAAI,WAAW,EAAE,CAAC;QACpC,IAAI,CAAC,SAAS;YAAE,SAAS;QACzB,MAAM,YAAY,GAAG,WAAW,CAAC,SAAS,CAAC,CAAC;QAC5C,IAAI,CAAC,IAAA,eAAU,EAAC,YAAY,CAAC,EAAE,CAAC;YAC9B,IAAA,cAAS,EAAC,YAAY,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,CAAC;IACH,CAAC;AACH,CAAC;AAED,SAAgB,gBAAgB,CAAC,MAAwB;IACvD,OAAO;QACL,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,MAAM,EAAE,MAAM,CAAC,MAAM;QACrB,QAAQ,EAAE,MAAM,CAAC,QAAQ;QACzB,MAAM,EAAE;YACN,KAAK,EAAE,SAAS;YAChB,GAAG,EAAE,UAAU;YACf,MAAM,EAAE,UAAU;YAClB,MAAM,EAAE,UAAU;SACnB;KACF,CAAC;AACJ,CAAC"}
+35 -2
View File
@@ -120,10 +120,12 @@ class InkEngine {
}
const paragraphs = [];
const globalTags = [];
const turnTags = [];
while (this.story.canContinue) {
const rawText = this.story.Continue();
const text = String(rawText || '').trim();
const tags = (0, tag_parser_1.parseTags)(this.story.currentTags || []);
turnTags.push(...tags);
tags
.filter((tag) => tag.key === 'title' || tag.key === 'author')
.forEach((tag) => globalTags.push(tag));
@@ -137,7 +139,7 @@ class InkEngine {
const choices = this.story.currentChoices.map((choice) => {
const tags = (0, tag_parser_1.parseTags)(choice.tags || []);
const category = (0, tag_parser_1.getTagValue)(tags, 'action');
const letter = (0, tag_parser_1.getTagValue)(tags, 'letter');
const letter = (0, tag_parser_1.getTagValue)(tags, 'letter') || (0, tag_parser_1.getTagValue)(tags, 'key');
return {
index: choice.index,
text: String(choice.text || '').trim(),
@@ -146,12 +148,43 @@ class InkEngine {
letter,
};
});
const inputMode = choices.length > 0 ? 'choice' : 'end';
const gameState = {};
if (inputMode === 'end') {
const errorTag = turnTags.find((tag) => tag.key === 'error');
const scoreTag = turnTags.find((tag) => tag.key === 'score');
if (!errorTag && !scoreTag) {
const message = 'Ink story ended without an explicit #score ending tag.';
const generatedErrorTag = { key: 'error', value: message };
globalTags.push(generatedErrorTag);
turnTags.push(generatedErrorTag);
}
const finalErrorTag = turnTags.find((tag) => tag.key === 'error');
const finalScoreTag = turnTags.find((tag) => tag.key === 'score');
if (finalErrorTag) {
gameState.endState = {
type: 'error',
message: finalErrorTag.value || finalErrorTag.param,
};
}
else if (finalScoreTag) {
const numericScore = Number(finalScoreTag?.value);
if (Number.isFinite(numericScore)) {
gameState.score = numericScore;
}
gameState.endState = {
type: 'intended',
message: finalScoreTag.value || finalScoreTag.param,
};
}
}
return {
turnId: this.nextTurnId++,
paragraphs,
choices,
inputMode: choices.length > 0 ? 'choice' : 'end',
inputMode,
globalTags: globalTags.length > 0 ? globalTags : undefined,
gameState: Object.keys(gameState).length > 0 ? gameState : undefined,
};
}
}
+1 -1
View File
File diff suppressed because one or more lines are too long
+4
View File
@@ -26,6 +26,10 @@ export interface TurnResult {
score?: number;
moves?: number;
statusLine?: string;
endState?: {
type: 'intended' | 'error';
message?: string;
};
};
suggestions?: string[];
}
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"turn-result.js","sourceRoot":"","sources":["../../src/interfaces/turn-result.ts"],"names":[],"mappings":";;AAyCA,4CA6BC;AAtED;;GAEG;AACH,oDAA+C;AAsC/C,SAAgB,gBAAgB,CAAC,IAAY,EAAE,OAAmB,EAAE;IAClE,OAAO,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;SACtB,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;SACvB,KAAK,CAAC,QAAQ,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;SACpC,MAAM,CAAC,OAAO,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE;QACjB,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpC,MAAM,aAAa,GAAe,CAAC,GAAG,IAAI,CAAC,CAAC;QAC5C,MAAM,SAAS,GAAa,EAAE,CAAC;QAC/B,IAAI,aAAa,GAAG,IAAI,CAAC;QAEzB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YAC5B,MAAM,QAAQ,GAAG,aAAa,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAA,qBAAQ,EAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAErF,IAAI,QAAQ,EAAE,CAAC;gBACb,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/B,CAAC;iBAAM,CAAC;gBACN,aAAa,GAAG,KAAK,CAAC;gBACtB,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACvB,CAAC;QACH,CAAC;QAED,OAAO;YACL,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE;YACjC,IAAI,EAAE,aAAa;SACpB,CAAC;IACJ,CAAC,CAAC,CAAC;AACP,CAAC"}
{"version":3,"file":"turn-result.js","sourceRoot":"","sources":["../../src/interfaces/turn-result.ts"],"names":[],"mappings":";;AA6CA,4CA6BC;AA1ED;;GAEG;AACH,oDAA+C;AA0C/C,SAAgB,gBAAgB,CAAC,IAAY,EAAE,OAAmB,EAAE;IAClE,OAAO,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;SACtB,OAAO,CAAC,QAAQ,EAAE,IAAI,CAAC;SACvB,KAAK,CAAC,QAAQ,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,CAAC;SACpC,MAAM,CAAC,OAAO,CAAC;SACf,GAAG,CAAC,CAAC,SAAS,EAAE,EAAE;QACjB,MAAM,KAAK,GAAG,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpC,MAAM,aAAa,GAAe,CAAC,GAAG,IAAI,CAAC,CAAC;QAC5C,MAAM,SAAS,GAAa,EAAE,CAAC;QAC/B,IAAI,aAAa,GAAG,IAAI,CAAC;QAEzB,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,OAAO,GAAG,IAAI,CAAC,IAAI,EAAE,CAAC;YAC5B,MAAM,QAAQ,GAAG,aAAa,IAAI,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAA,qBAAQ,EAAC,OAAO,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC;YAErF,IAAI,QAAQ,EAAE,CAAC;gBACb,aAAa,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;YAC/B,CAAC;iBAAM,CAAC;gBACN,aAAa,GAAG,KAAK,CAAC;gBACtB,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACvB,CAAC;QACH,CAAC;QAED,OAAO;YACL,IAAI,EAAE,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE;YACjC,IAAI,EAAE,aAAa;SACpB,CAAC;IACJ,CAAC,CAAC,CAAC;AACP,CAAC"}
+8
View File
@@ -25,6 +25,14 @@ function parseTag(raw) {
tag.param = bracketMatch[3].trim();
return tag;
}
const colonMatch = text.match(/^([A-Za-z][\w-]*)\s*:\s*(.*?)\s*(?:\(([^)]*)\))?$/);
if (colonMatch) {
const tag = { key: normalizeKey(colonMatch[1]) };
tag.value = colonMatch[2].trim();
if (typeof colonMatch[3] !== 'undefined')
tag.param = colonMatch[3].trim();
return tag;
}
const bareMatch = text.match(/^[A-Za-z][\w-]*$/);
if (bareMatch) {
return { key: normalizeKey(text) };
+1 -1
View File
@@ -1 +1 @@
{"version":3,"file":"tag-parser.js","sourceRoot":"","sources":["../../src/utils/tag-parser.ts"],"names":[],"mappings":";;AAaA,4BAkBC;AAED,8BAMC;AAED,kCAGC;AA1CD,MAAM,kBAAkB,GAA2B;IACjD,KAAK,EAAE,KAAK;IACZ,SAAS,EAAE,OAAO;IAClB,SAAS,EAAE,SAAS;CACrB,CAAC;AAEF,SAAS,YAAY,CAAC,GAAW;IAC/B,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC,CAAC;IAC1E,OAAO,kBAAkB,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC;AACtD,CAAC;AAED,SAAgB,QAAQ,CAAC,GAAW;IAClC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IAC3D,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,MAAM,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,qDAAqD,CAAC,CAAC;IACvF,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,GAAG,GAAa,EAAE,GAAG,EAAE,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,OAAO,GAAG,CAAC;IACb,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;IACjD,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,EAAE,GAAG,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;IACrC,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAgB,SAAS,CAAC,OAA8B;IACtD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IAEvC,OAAO,OAAO;SACX,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC;SACzC,MAAM,CAAC,CAAC,GAAG,EAAmB,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;AACpD,CAAC;AAED,SAAgB,WAAW,CAAC,IAAgB,EAAE,GAAW;IACvD,MAAM,aAAa,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,aAAa,CAAC,EAAE,KAAK,CAAC;AAC9D,CAAC"}
{"version":3,"file":"tag-parser.js","sourceRoot":"","sources":["../../src/utils/tag-parser.ts"],"names":[],"mappings":";;AAaA,4BA0BC;AAED,8BAMC;AAED,kCAGC;AAlDD,MAAM,kBAAkB,GAA2B;IACjD,KAAK,EAAE,KAAK;IACZ,SAAS,EAAE,OAAO;IAClB,SAAS,EAAE,SAAS;CACrB,CAAC;AAEF,SAAS,YAAY,CAAC,GAAW;IAC/B,MAAM,UAAU,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,CAAC,OAAO,CAAC,eAAe,EAAE,GAAG,CAAC,CAAC;IAC1E,OAAO,kBAAkB,CAAC,UAAU,CAAC,IAAI,UAAU,CAAC;AACtD,CAAC;AAED,SAAgB,QAAQ,CAAC,GAAW;IAClC,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;IAC3D,IAAI,CAAC,IAAI;QAAE,OAAO,IAAI,CAAC;IAEvB,MAAM,YAAY,GAAG,IAAI,CAAC,KAAK,CAAC,qDAAqD,CAAC,CAAC;IACvF,IAAI,YAAY,EAAE,CAAC;QACjB,MAAM,GAAG,GAAa,EAAE,GAAG,EAAE,YAAY,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC7D,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,IAAI,OAAO,YAAY,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC/E,OAAO,GAAG,CAAC;IACb,CAAC;IAED,MAAM,UAAU,GAAG,IAAI,CAAC,KAAK,CAAC,mDAAmD,CAAC,CAAC;IACnF,IAAI,UAAU,EAAE,CAAC;QACf,MAAM,GAAG,GAAa,EAAE,GAAG,EAAE,YAAY,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,GAAG,CAAC,KAAK,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QACjC,IAAI,OAAO,UAAU,CAAC,CAAC,CAAC,KAAK,WAAW;YAAE,GAAG,CAAC,KAAK,GAAG,UAAU,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAC3E,OAAO,GAAG,CAAC;IACb,CAAC;IAED,MAAM,SAAS,GAAG,IAAI,CAAC,KAAK,CAAC,kBAAkB,CAAC,CAAC;IACjD,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,EAAE,GAAG,EAAE,YAAY,CAAC,IAAI,CAAC,EAAE,CAAC;IACrC,CAAC;IAED,OAAO,IAAI,CAAC;AACd,CAAC;AAED,SAAgB,SAAS,CAAC,OAA8B;IACtD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,OAAO,CAAC;QAAE,OAAO,EAAE,CAAC;IAEvC,OAAO,OAAO;SACX,GAAG,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,CAAC;SACzC,MAAM,CAAC,CAAC,GAAG,EAAmB,EAAE,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;AACpD,CAAC;AAED,SAAgB,WAAW,CAAC,IAAgB,EAAE,GAAW;IACvD,MAAM,aAAa,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,CAAC,GAAG,KAAK,aAAa,CAAC,EAAE,KAAK,CAAC;AAC9D,CAAC"}
+1 -1
View File
@@ -56,7 +56,7 @@ This means:
All engines author tags using the same `key[value]` bracket convention. For Ink this maps directly to native `# key[value]` tags. For YAML and Z-Code the server synthesises equivalent tag objects.
Ink choice text uses square brackets for its own display/control syntax. For choice-local tags, the integration therefore also accepts the prototype form `# key: value` and normalises it to the same structured tag object. Prefer `# letter: o` and `# action: examine` on choices when inkjs treats bracketed tags as choice text.
Ink choice text uses square brackets for its own display/control syntax, but choice-local tags still use the normal tag syntax on their own tag lines below the choice. The currently implemented parser accepts `# letter[o]` and `# action[examine]`; the earlier prototype-style `# key: value` notation is not part of the active syntax.
| Tag | Scope | Meaning |
|---|---|---|
+116
View File
@@ -0,0 +1,116 @@
# Third-Party Notices
This application includes or interfaces with the following third-party libraries, tools, fonts, and services.
## Browser-vendored libraries
| Component | Local files | Local version | Latest checked | License | Status |
| --- | --- | --- | --- | --- | --- |
| SmartyPants.js | `public/js/smartypants.js` | Header says 0.0.6 | npm `smartypants` 0.2.2 | BSD-3-Clause | Local file is not identical to npm `smartypants` 0.0.5, 0.0.9, or 0.2.2. It appears to be an older browser bundle with local or unreleased changes. |
| Hyphenopoly | `public/js/Hyphenopoly.js`, `public/js/Hyphenopoly_Loader.js`, `public/js/hyphenopoly.module.js`, `public/js/patterns/*.wasm` | Browser file header says 5.2.0-beta.1 | npm `hyphenopoly` 6.1.0 | MIT | `Hyphenopoly.js` matches the 5.2.0-beta.1 npm file after line-ending normalization. Loader differs by a small local/prototype change. Package dependency is 6.0.0, so the browser vendored copy is older than both the installed package and latest npm. |
| Knuth-Plass line breaking adapter | `public/js/knuth-and-plass.js` | No upstream version header | Unknown | Unknown/inherited prototype code | Local file differs from the prototype file and is application-adapted. Exact upstream could not be identified from file headers or npm metadata. |
| Line breaking support | `public/js/linebreak.js`, `public/js/linked-list.js` | No upstream version header | Unknown | Unknown/inherited prototype code | Files are identical to the prototype copies. Exact upstream could not be identified from file headers. |
| Kokoro JS browser bundle | `public/js/kokoro-js.js` | 1.2.0 | npm `kokoro-js` 1.2.1 | Apache-2.0 | Local file is byte-identical to `kokoro-js` 1.2.0 `dist/kokoro.web.js`; not latest. |
## Direct npm runtime dependencies
| Package | Installed | Latest checked | License | Credit |
| --- | --- | --- | --- | --- |
| `inkjs` | 2.4.0 | 2.4.0 | MIT | Yannick Lohse; based on ink by Inkle |
| `hyphenopoly` | 6.0.0 | 6.1.0 | MIT | Mathias Nater |
| `kokoro-js` | 1.2.0 | 1.2.1 | Apache-2.0 | hexgrad, Xenova |
| `ifvms` | 1.1.6 | 1.1.6 | MIT | Dannii Willis |
| `openai` | 4.91.0 | 6.38.0 | Apache-2.0 | OpenAI |
| `socket.io` | 4.8.1 | 4.8.3 | MIT | Socket.IO contributors |
| `express` | 5.1.0 | 5.2.1 | MIT | Express contributors |
| `axios` | 1.8.4 | 1.16.1 | MIT | Axios contributors |
| `cors` | 2.8.5 | 2.8.6 | MIT | Troy Goode |
| `dotenv` | 16.4.7 | 17.4.2 | BSD-2-Clause | dotenv contributors |
| `js-yaml` | 4.1.0 | 4.1.1 | MIT | Vladimir Zapparov and contributors |
## Fonts and services
| Component | Use | License/Credit |
| --- | --- | --- |
| EB Garamond | UI and book text font | SIL Open Font License 1.1 |
| OpenAI / ChatGPT / Codex / GPT-image-2 | Coding assistance, writing assistance, generated images | OpenAI |
| Claude Code | Coding assistance | Anthropic |
| Suno | Music generation | Suno |
## Creative credits
Produced by Bad Tools Studio.
Runtime server programming: Georg Tomitsch, OpenAI Codex
Game engine: ink by Inkle; inkjs by Yannick Lohse
Client and UI programming: Georg Tomitsch, OpenAI Codex, Claude Code
UI visual design: Georg Tomitsch
Story: Georg Tomitsch
Writing: Georg Tomitsch, ChatGPT
Music: Georg Tomitsch, Suno
Art direction: Georg Tomitsch
Images: OpenAI GPT-image-2
## Links
- Inkle ink: https://www.inklestudios.com/ink/
- inkjs: https://www.npmjs.com/package/inkjs
- SmartyPants.js: https://github.com/othree/smartypants.js
- Hyphenopoly: https://mnater.github.io/Hyphenopoly/
- Kokoro JS: https://github.com/hexgrad/kokoro
- ifvms.js: https://github.com/curiousdannii/ifvms.js
- OpenAI: https://openai.com/
- ChatGPT: https://chatgpt.com/
- Claude Code: https://www.anthropic.com/claude-code
- Suno: https://suno.com/
## License Texts
### MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy of software and associated documentation files licensed under the MIT License, to deal in the software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the software, and to permit persons to whom the software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
### BSD 2-Clause License
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
### BSD 3-Clause License
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the copyright notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
### Apache License 2.0
Licensed under the Apache License, Version 2.0. You may not use files licensed under the Apache License except in compliance with the License. You may obtain a copy of the License at:
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the Apache License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
### SIL Open Font License 1.1
EB Garamond is distributed under the SIL Open Font License, Version 1.1. The license permits use, study, modification and redistribution of the font, with reserved font name restrictions and the requirement that derivative fonts remain under the same license.
Full license: https://openfontlicense.org/
+296 -39
View File
@@ -116,17 +116,17 @@ body.switched {
--book-height: 799px;
--book-scale: 1;
--page-line-count: 25;
--book-page-top: 7.35%;
--book-page-bottom: 14.15%;
--book-page-top: 4.00%;
--book-page-bottom: 19%;
--book-page-height: calc(100% - var(--book-page-top) - var(--book-page-bottom));
--book-left-page-left: 10.7%;
--book-left-page-width: 33.75%;
--book-right-page-right: 14.85%;
--book-right-page-width: 32.65%;
--book-left-page-left: 14%;
--book-left-page-width: 34.9%;
--book-right-page-right: 14%;
--book-right-page-width: 34%;
--book-page-perspective: 3200px;
--book-left-page-transform: none;
--book-right-page-transform: none;
--story-line-height: calc((var(--book-height) * 0.785) / var(--page-line-count));
--story-line-height: calc((var(--book-height) * 0.77) / var(--page-line-count));
--story-font-size: calc(var(--story-line-height) / 1.45);
font-size: calc(var(--book-height)/(34 * 1.5));
--img-aspect-ratio: 1.779;
@@ -342,13 +342,14 @@ ol.choice {
position: absolute;
right: 0;
left: 0;
top: -0.6rem;
top: 0.45rem;
user-select: none;
transition: color 0.6s, background 0.6s;
font-size: 0.82rem;
}
#controls [disabled] {
color: #999;
color: #8f806a;
}
#controls input[type=range] {
@@ -458,7 +459,8 @@ ol.choice {
margin: 0 auto;
padding: 0;
overflow: hidden;
background: transparent;
background: rgba(218, 188, 130, 0.18);
background-blend-mode: multiply;
}
.story-image-block img {
@@ -466,8 +468,9 @@ ol.choice {
width: 100%;
height: 100%;
object-fit: contain;
mix-blend-mode: multiply;
filter: contrast(1.05);
mix-blend-mode: color-burn;
/* filter: grayscale(0.72) sepia(0.18) contrast(1.22) brightness(0.96); */
opacity: 0.94;
}
.story-image-landscape,
@@ -641,7 +644,7 @@ ol.choice {
top: 0px;
left: 0px;
border: 1px none red;
background-color: #fff;
background-color: #d8bf89;
box-shadow: 2px 2px 2px rgba(0,0,0,0.3);
}
@@ -693,28 +696,243 @@ ol.choice {
color: rgba(0, 0, 0, 0.62);
}
#lighting {
position: absolute;
top: -35%;
left: -35%;
width: 180%;
height: 180%;
animation: gradient-animation-shrink 1s 1;
background: radial-gradient(circle, rgba(255,240,182,0.1) 0%, rgba(255,237,165,0.2) 20%, rgba(0,0,0,0.9) 65%, rgba(0,0,0,0.9) 100%);
mix-blend-mode: color-burn;
pointer-events: none; /* makes the element ignore mouse events, and pass them to elements underneath */
z-index: 999; /* should be high enough to be on top of other elements */
#credits_button {
border: 0;
padding: 0;
margin: 0;
background: transparent;
color: rgba(0, 0, 0, 0.72);
font: inherit;
font-style: italic;
cursor: pointer;
pointer-events: auto;
}
@keyframes gradient-animation-grow {
0% { width: 180%; height: 180%; left: -35%; top: -35%; }
100% { width: 170%; height: 170%; left: -33%; top: -33%; }
}
#credits_button:hover {
color: rgba(0, 0, 0, 0.95);
}
@keyframes gradient-animation-shrink {
0% { width: 170%; height: 170%; left: -33%; top: -33%; }
100% { width: 180%; height: 180%; left: -35%; top: -35%; }
}
.credits-modal {
position: fixed;
inset: 0;
z-index: 1200;
display: none;
align-items: center;
justify-content: center;
padding: 4vh 4vw;
background: rgba(10, 7, 4, 0.52);
}
.credits-modal.visible {
display: flex;
}
.credits-dialog {
width: min(48rem, 92vw);
height: min(44rem, 88vh);
background: rgba(229, 205, 155, 0.96);
color: rgba(24, 18, 12, 0.92);
border: 1px solid rgba(42, 31, 18, 0.45);
box-shadow: 0 1.2rem 3rem rgba(0, 0, 0, 0.45);
padding: 1.2rem;
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.credits-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid rgba(42, 31, 18, 0.28);
padding-bottom: 0.55rem;
}
.credits-dialog-header h2 {
margin: 0;
font-size: 1.35rem;
font-style: italic;
}
#credits_close {
border: 1px solid rgba(42, 31, 18, 0.5);
background: rgba(255, 246, 220, 0.35);
color: rgba(24, 18, 12, 0.9);
font: inherit;
padding: 0.18rem 0.7rem;
cursor: pointer;
}
.credits-logo-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.55rem;
}
.credits-logo-row a {
color: rgba(31, 23, 15, 0.88);
text-decoration: none;
}
.credits-logo-row img {
display: block;
width: 1.3rem;
height: 1.3rem;
}
.credits-wordmark {
border: 1px solid rgba(42, 31, 18, 0.34);
padding: 0.08rem 0.45rem;
background: rgba(255, 246, 220, 0.24);
font-style: italic;
}
.story-popup-modal {
position: fixed;
inset: 0;
z-index: 1250;
display: none;
align-items: center;
justify-content: center;
padding: 4vh 4vw;
background: rgba(10, 7, 4, 0.42);
}
.story-popup-modal.visible {
display: flex;
}
.story-popup-dialog {
width: min(30rem, 88vw);
max-height: min(28rem, 80vh);
overflow: auto;
background: rgba(228, 202, 149, 0.97);
color: rgba(22, 16, 10, 0.94);
border: 1px solid rgba(42, 31, 18, 0.5);
box-shadow: 0 1.1rem 2.6rem rgba(0, 0, 0, 0.42);
padding: 1.15rem 1.3rem 1rem;
display: flex;
flex-direction: column;
gap: 0.8rem;
font-family: 'EB Garamond', var(--book-font), serif;
}
.story-popup-dialog h2 {
margin: 0;
font-size: 1.45rem;
font-style: italic;
line-height: 1.15;
}
#story_popup_message {
white-space: pre-wrap;
line-height: 1.35;
font-size: 1.05rem;
}
#story_popup_ok {
align-self: flex-end;
border: 1px solid rgba(42, 31, 18, 0.5);
background: rgba(255, 246, 220, 0.36);
color: rgba(24, 18, 12, 0.92);
font: inherit;
padding: 0.18rem 0.85rem;
cursor: pointer;
}
.credits-content {
flex: 1;
overflow: auto;
margin: 0;
padding: 0.8rem;
background: rgba(255, 246, 220, 0.2);
border: 1px solid rgba(42, 31, 18, 0.22);
white-space: pre-wrap;
font-family: "EB Garamond", serif;
font-size: 0.92rem;
line-height: 1.28;
}
#lighting {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 999;
overflow: hidden;
}
#lighting::before,
#lighting::after {
content: "";
position: absolute;
inset: -18%;
pointer-events: none;
}
#lighting::before {
background:
radial-gradient(circle at 16% 5%,
rgba(255, 214, 150, 0.36) 0%,
rgba(210, 126, 64, 0.20) 14%,
rgba(120, 65, 38, 0.08) 34%,
rgba(60, 36, 24, 0.025) 58%,
rgba(60, 36, 24, 0) 76%);
mix-blend-mode: soft-light;
opacity: 0.42;
animation: candle-flicker 6.8s infinite ease-in-out;
will-change: opacity, transform, filter;
}
#lighting::after {
background:
radial-gradient(ellipse at center,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.035) 50%,
rgba(0, 0, 0, 0.18) 100%);
mix-blend-mode: multiply;
opacity: 0.24;
}
@keyframes candle-flicker {
0% {
opacity: 0.36;
filter: brightness(0.99) sepia(0.08) saturate(1.02);
transform: translate3d(0, 0, 0) scale(1);
}
18% {
opacity: 0.46;
filter: brightness(1.06) sepia(0.12) saturate(1.07);
transform: translate3d(0.18%, -0.08%, 0) scale(1.008);
}
31% {
opacity: 0.39;
filter: brightness(1.01) sepia(0.09) saturate(1.04);
transform: translate3d(-0.08%, 0.08%, 0) scale(1.003);
}
49% {
opacity: 0.5;
filter: brightness(1.08) sepia(0.14) saturate(1.08);
transform: translate3d(0.24%, -0.13%, 0) scale(1.011);
}
64% {
opacity: 0.41;
filter: brightness(1.02) sepia(0.1) saturate(1.04);
transform: translate3d(-0.12%, 0.1%, 0) scale(1.004);
}
81% {
opacity: 0.47;
filter: brightness(1.065) sepia(0.13) saturate(1.07);
transform: translate3d(0.14%, -0.06%, 0) scale(1.008);
}
100% {
opacity: 0.36;
filter: brightness(1) sepia(0.08) saturate(1.02);
transform: translate3d(0, 0, 0) scale(1);
}
}
/* Command history */
#command_history {
@@ -723,10 +941,10 @@ ol.choice {
font-size: 1rem;
line-height: 1.2;
margin-bottom: 1rem;
border-top: 1px solid #d1c8b9;
border-top: 1px solid #b69b68;
padding-top: 0.6rem;
scrollbar-width: thin;
scrollbar-color: #8b7765 rgba(255, 255, 255, 0.1);
scrollbar-color: #7b654a rgba(151, 111, 64, 0.12);
}
body:not([data-game-running="true"]) #command_history {
@@ -754,11 +972,11 @@ body:not([data-game-running="true"]) #command_history {
}
#command_history::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
background: rgba(151, 111, 64, 0.12);
}
#command_history::-webkit-scrollbar-thumb {
background-color: #8b7765;
background-color: #7b654a;
border-radius: 4px;
}
@@ -918,7 +1136,7 @@ html[data-process-state="playing-ready"] * {
/* Placeholder styling - lighter and italic, with padding to avoid cursor overlap */
#player_input::placeholder {
color: #aaa;
color: #8f806a;
font-style: italic;
padding-left: 15px; /* Add padding to move placeholder text to the right */
}
@@ -1044,7 +1262,7 @@ body:not([data-game-running="true"]) #command_input {
#start_prompt {
position: absolute;
inset: 0;
display: none;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
@@ -1053,11 +1271,13 @@ body:not([data-game-running="true"]) #command_input {
font-size: 34px;
font-style: italic;
color: rgba(45, 34, 24, 0.78);
opacity: 0;
pointer-events: none;
transition: opacity 260ms ease;
}
body:not([data-game-running="true"]) #start_prompt {
display: flex;
opacity: 1;
}
/* Options Modal Styling */
@@ -1214,6 +1434,43 @@ body:not([data-game-running="true"]) #start_prompt {
text-align: right;
}
.option-item .game-language-value {
min-width: 8rem;
text-align: right;
font-style: italic;
}
.volume-option {
justify-content: flex-start;
}
.volume-option label {
flex: 1 1 auto;
}
.volume-toggle {
width: 1.7rem;
height: 1.4rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid black;
border-radius: 0.25rem;
background: transparent;
color: rgba(0,0,0,0.9);
cursor: pointer;
font-size: 0.85rem;
line-height: 1;
}
.volume-toggle:hover {
background-color: rgba(0,0,0,0.06);
}
.volume-toggle.is-muted {
opacity: 0.55;
}
.provider-status-list {
margin: 0.5rem 0 1rem;
border: 1px solid #e9ddc8;
+5 -2
View File
@@ -7,7 +7,8 @@ Image block markup:
```text
#image[image-name.jpg](landscape)
#image[image-name.jpg](portrait)
#image[image-name.jpg](portrait pause=2)
#image[image-name.jpg](square lead=1.5)
```
Sizes:
@@ -16,7 +17,9 @@ Sizes:
- `portrait`: 16:9, half page width, height snapped to whole line heights, with following prose flowing beside it.
- `square`: 1:1, centered, near full page width, height snapped to whole line heights.
Image markup is parsed and queued by the story markup system, but final image rendering is still future work. Keep assets ready for that renderer by using browser-friendly formats such as `.jpg`, `.png`, `.webp`, or `.avif`.
Images are inserted as story blocks, saved in browser history, restored on load/history scrolling, and revealed after the page scrolls to their line-snapped position. Optional `pause=`, `delay=`, `lead=`, or bare seconds such as `2s` delay the next spoken paragraph; the pause is skippable and does not block background TTS preparation.
Use browser-friendly formats such as `.jpg`, `.png`, `.webp`, or `.avif`.
Use file names that are stable and story-facing, for example `mansion-door-rain.webp` rather than temporary export names.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

+14 -11
View File
@@ -9,7 +9,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
super(id, name);
// Declare proper dependencies according to architecture principles
this.dependencies = ['persistence-manager', 'localization'];
this.dependencies = ['persistence-manager', 'localization', 'game-config'];
// Basic voice options
this.voiceOptions = {
@@ -86,7 +86,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
return;
}
if (['masterVolume', 'ttsVolume', 'master_volume', 'tts_volume'].includes(key)) {
if (['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled', 'master_volume', 'tts_volume'].includes(key)) {
this.applyCurrentVolume();
}
});
@@ -129,9 +129,9 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
*/
async setupVoiceFromPreferences() {
const persistenceManager = this.getModule('persistence-manager');
const localization = this.getModule('localization');
const gameConfig = this.getModule('game-config');
if (!persistenceManager || !localization) {
if (!persistenceManager) {
console.error(`${this.name}: Required dependencies not found`);
return false;
}
@@ -140,7 +140,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
const preferredVoiceId = persistenceManager.getPreference('tts', `${this.id}_voice`, '');
// Get current locale
const currentLocale = localization.getLocale();
const currentLocale = gameConfig?.getLocale?.() || 'en_US';
// If we have a preferred voice ID, use it
if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) {
@@ -194,7 +194,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
* @param {string} text - The text to synthesize.
* @returns {Promise<Object>} - A promise that resolves with the audio data object.
*/
async generateSpeechAudio(text) {
async generateSpeechAudio(text, options = {}) {
// To be implemented by subclasses
return { success: false, reason: 'not_implemented' };
}
@@ -206,10 +206,11 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
* @returns {Promise<Object>} - Resolves when audio finishes playing
*/
async speakPreloaded(preloadData, callback = null) {
const completionCallback = typeof callback === 'function' ? callback : null;
if (!preloadData || !preloadData.audioData) {
console.error(`${this.name}: Invalid preloaded data`);
const result = { success: false, reason: 'invalid_data' };
if (callback) callback(result);
if (completionCallback) completionCallback(result);
return result;
}
@@ -242,7 +243,7 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
}
URL.revokeObjectURL(audioUrl);
if (callback) callback(result);
if (completionCallback) completionCallback(result);
resolve(result);
};
this.currentPlaybackFinish = finish;
@@ -284,13 +285,15 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
'masterVolume',
persistenceManager.getPreference('audio', 'master_volume', 1.0)
);
const masterEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', true) !== false;
const ttsVolume = persistenceManager.getPreference(
'audio',
'ttsVolume',
persistenceManager.getPreference('audio', 'tts_volume', 1.0)
);
const ttsEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', true) !== false;
return Math.max(0, Math.min(1, masterVolume * ttsVolume));
return Math.max(0, Math.min(1, (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
}
/**
@@ -418,14 +421,14 @@ export class ApiTTSModuleBase extends TTSHandlerModule {
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Preloaded speech data
*/
async preloadSpeech(text) {
async preloadSpeech(text, options = {}) {
if (!this.isReady) {
return { success: false, reason: 'not_ready' };
}
try {
// Generate speech
const result = await this.generateSpeechAudio(text);
const result = await this.generateSpeechAudio(text, options);
if (!result.success) {
return { success: false, reason: 'generation_failed' };
+78 -7
View File
@@ -10,6 +10,7 @@ class AudioManagerModule extends BaseModule {
this.sounds = new Map();
this.sfxCache = new Map();
this.currentAudio = null;
this.currentAudioRole = null;
this.currentLoop = null;
this.currentMusic = null;
this.queuedMusic = null;
@@ -17,6 +18,12 @@ class AudioManagerModule extends BaseModule {
this.musicVolume = 1.0;
this.sfxVolume = 1.0;
this.ttsVolume = 1.0;
this.masterVolumeEnabled = true;
this.musicVolumeEnabled = true;
this.sfxVolumeEnabled = true;
this.ttsVolumeEnabled = true;
this.musicDuckingAmount = 0.3;
this.musicDuckingEnabled = true;
this.musicDuckingFactor = 1.0;
this.musicFadeToken = 0;
this.activeTtsPlaybackCount = 0;
@@ -79,6 +86,12 @@ class AudioManagerModule extends BaseModule {
this.musicVolume = this.clampVolume(persistenceManager.getPreference('audio', 'musicVolume', this.musicVolume));
this.sfxVolume = this.clampVolume(persistenceManager.getPreference('audio', 'sfxVolume', this.sfxVolume));
this.ttsVolume = this.clampVolume(persistenceManager.getPreference('audio', 'ttsVolume', this.ttsVolume));
this.masterVolumeEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', this.masterVolumeEnabled) !== false;
this.musicVolumeEnabled = persistenceManager.getPreference('audio', 'musicVolumeEnabled', this.musicVolumeEnabled) !== false;
this.sfxVolumeEnabled = persistenceManager.getPreference('audio', 'sfxVolumeEnabled', this.sfxVolumeEnabled) !== false;
this.ttsVolumeEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', this.ttsVolumeEnabled) !== false;
this.musicDuckingAmount = this.clampVolume(persistenceManager.getPreference('audio', 'musicDuckingAmount', this.musicDuckingAmount));
this.musicDuckingEnabled = persistenceManager.getPreference('audio', 'musicDuckingEnabled', this.musicDuckingEnabled) !== false;
}
setupEventListeners() {
@@ -108,6 +121,12 @@ class AudioManagerModule extends BaseModule {
if (key === 'musicVolume') this.setMusicVolume(value);
if (key === 'sfxVolume') this.setSfxVolume(value);
if (key === 'ttsVolume') this.setTtsVolume(value);
if (key === 'masterVolumeEnabled') this.setVolumeEnabled('master', value);
if (key === 'musicVolumeEnabled') this.setVolumeEnabled('music', value);
if (key === 'sfxVolumeEnabled') this.setVolumeEnabled('sfx', value);
if (key === 'ttsVolumeEnabled') this.setVolumeEnabled('tts', value);
if (key === 'musicDuckingAmount') this.setMusicDuckingAmount(value);
if (key === 'musicDuckingEnabled') this.setMusicDuckingEnabled(value);
});
this.addEventListener(document, 'tts:playback-start', () => {
@@ -204,6 +223,7 @@ class AudioManagerModule extends BaseModule {
this.currentLoop = audio;
} else {
this.currentAudio = audio.cloneNode(true);
this.currentAudioRole = 'sfx';
this.setMediaVolume(this.currentAudio, this.getSfxVolume());
this.currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
@@ -241,6 +261,7 @@ class AudioManagerModule extends BaseModule {
return this.currentLoop;
} else {
this.currentAudio = new Audio(url);
this.currentAudioRole = 'sfx';
this.setMediaVolume(this.currentAudio, this.getSfxVolume());
this.currentAudio.play().catch(error => {
console.error('Error playing audio:', error);
@@ -269,6 +290,7 @@ class AudioManagerModule extends BaseModule {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
this.currentAudio = null;
this.currentAudioRole = null;
}
if (this.currentLoop) {
@@ -306,6 +328,7 @@ class AudioManagerModule extends BaseModule {
*/
setTtsVolume(volume) {
this.ttsVolume = this.clampVolume(volume);
this.updateVolumes();
}
/**
@@ -325,6 +348,33 @@ class AudioManagerModule extends BaseModule {
this.sfxVolume = this.clampVolume(volume);
this.updateVolumes();
}
setVolumeEnabled(kind, enabled) {
const value = enabled !== false;
if (kind === 'master') this.masterVolumeEnabled = value;
if (kind === 'music') this.musicVolumeEnabled = value;
if (kind === 'sfx') this.sfxVolumeEnabled = value;
if (kind === 'tts') this.ttsVolumeEnabled = value;
this.updateVolumes();
}
setMusicDuckingAmount(amount) {
this.musicDuckingAmount = this.clampVolume(amount);
if (this.musicDuckingFactor !== 1.0) {
this.duckMusicForSpeech();
} else {
this.updateVolumes();
}
}
setMusicDuckingEnabled(enabled) {
this.musicDuckingEnabled = enabled !== false;
if (this.musicDuckingFactor !== 1.0) {
this.duckMusicForSpeech();
} else {
this.updateVolumes();
}
}
/**
* Update all volume levels based on current settings
@@ -332,11 +382,11 @@ class AudioManagerModule extends BaseModule {
updateVolumes() {
this.sounds.forEach(audio => {
const isMusic = audio.loop;
this.setMediaVolume(audio, this.masterVolume * (isMusic ? this.musicVolume : this.sfxVolume));
this.setMediaVolume(audio, isMusic ? this.getMusicVolume() : this.getSfxVolume());
});
if (this.currentAudio) {
this.setMediaVolume(this.currentAudio, this.masterVolume * this.sfxVolume);
this.setMediaVolume(this.currentAudio, this.currentAudioRole === 'tts' ? this.getTtsVolume() : this.getSfxVolume());
}
if (this.currentLoop) {
@@ -358,20 +408,29 @@ class AudioManagerModule extends BaseModule {
}
getSfxVolume() {
return this.masterVolume * this.sfxVolume;
return this.getMasterVolume() * (this.sfxVolumeEnabled ? this.sfxVolume : 0);
}
getMusicVolume() {
return this.masterVolume * this.musicVolume * this.musicDuckingFactor;
return this.getUnduckedMusicVolume() * this.musicDuckingFactor;
}
getUnduckedMusicVolume() {
return this.masterVolume * this.musicVolume;
return this.getMasterVolume() * (this.musicVolumeEnabled ? this.musicVolume : 0);
}
getMasterVolume() {
return this.masterVolumeEnabled ? this.masterVolume : 0;
}
getTtsVolume() {
return this.getMasterVolume() * (this.ttsVolumeEnabled ? this.ttsVolume : 0);
}
duckMusicForSpeech() {
console.log('AudioManager: Ducking music for TTS playback');
this.fadeMusicTo(0.3, 500);
const factor = this.musicDuckingEnabled ? 1 - this.musicDuckingAmount : 1;
this.fadeMusicTo(factor, 500);
}
restoreMusicAfterSpeech() {
@@ -564,6 +623,7 @@ class AudioManagerModule extends BaseModule {
const audio = template.cloneNode(true);
this.setMediaVolume(audio, this.getSfxVolume());
this.currentAudio = audio;
this.currentAudioRole = 'sfx';
const maxDuration = Math.max(0, Number(options.maxDurationSeconds || options.maxDuration || 0)) * 1000;
const endMode = String(options.endMode || options.mode || 'stop').toLowerCase().startsWith('fade') ? 'fade' : 'stop';
const fadeDuration = Math.max(100, Number(options.fadeDurationSeconds || options.fadeDuration || 2) * 1000);
@@ -572,6 +632,7 @@ class AudioManagerModule extends BaseModule {
if (maxTimer) clearTimeout(maxTimer);
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioRole = null;
}
}, { once: true });
await audio.play();
@@ -588,6 +649,7 @@ class AudioManagerModule extends BaseModule {
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
if (this.currentAudio === null) this.currentAudioRole = null;
}
}, timeoutDuration);
}
@@ -614,6 +676,7 @@ class AudioManagerModule extends BaseModule {
audio.pause();
audio.currentTime = 0;
if (this.currentAudio === audio) this.currentAudio = null;
if (this.currentAudio === null) this.currentAudioRole = null;
resolve(true);
};
requestAnimationFrame(step);
@@ -832,6 +895,10 @@ class AudioManagerModule extends BaseModule {
audio.pause();
audio.currentTime = 0;
this.setMediaVolume(audio, initialVolume); // Reset volume for future use
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioRole = null;
}
resolve();
} else {
this.setMediaVolume(audio, currentVolume);
@@ -860,6 +927,7 @@ class AudioManagerModule extends BaseModule {
if (this.currentAudio) {
this.currentAudio.pause();
this.currentAudio.currentTime = 0;
this.currentAudioRole = null;
}
// Create new audio element
@@ -880,13 +948,14 @@ class AudioManagerModule extends BaseModule {
}
// Apply master volume and speech volume
this.setMediaVolume(audio, this.masterVolume * speechVolume * this.ttsVolume);
this.setMediaVolume(audio, speechVolume * this.getTtsVolume());
// Set up cleanup
audio.onended = () => {
URL.revokeObjectURL(audioUrl);
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioRole = null;
}
if (options.onComplete && typeof options.onComplete === 'function') {
options.onComplete();
@@ -902,6 +971,7 @@ class AudioManagerModule extends BaseModule {
URL.revokeObjectURL(audioUrl);
if (this.currentAudio === audio) {
this.currentAudio = null;
this.currentAudioRole = null;
}
if (options.onError && typeof options.onError === 'function') {
options.onError(error);
@@ -915,6 +985,7 @@ class AudioManagerModule extends BaseModule {
// Store as current audio
this.currentAudio = audio;
this.currentAudioRole = 'tts';
// Play the audio
await audio.play();
+31 -9
View File
@@ -9,7 +9,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
super('browser-tts', 'Browser TTS');
// Declare proper dependencies according to architecture principles
this.dependencies = ['persistence-manager', 'localization'];
this.dependencies = ['persistence-manager', 'localization', 'game-config'];
// Voice options
this.voiceOptions = {
@@ -57,6 +57,13 @@ export class BrowserTTSModule extends TTSHandlerModule {
console.error('Browser TTS: Localization dependency not found');
return false;
}
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key } = event.detail || {};
if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentUtterance) {
this.currentUtterance.volume = this.getPlaybackVolume();
}
});
// Check if browser supports speech synthesis
if (!window.speechSynthesis) {
@@ -163,9 +170,9 @@ export class BrowserTTSModule extends TTSHandlerModule {
*/
async setupVoiceFromPreferences() {
const persistenceManager = this.getModule('persistence-manager');
const localization = this.getModule('localization');
const gameConfig = this.getModule('game-config');
if (!persistenceManager || !localization || this.voices.length === 0) {
if (!persistenceManager || this.voices.length === 0) {
return false;
}
@@ -173,7 +180,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
const preferredVoiceId = persistenceManager.getPreference('tts', 'browser_voice', '');
// Get current locale
const currentLocale = localization.getLocale();
const currentLocale = gameConfig?.getLocale?.() || 'en_US';
// If we have a preferred voice ID, use it
if (preferredVoiceId && this.voices.some(v => v.id === preferredVoiceId)) {
@@ -200,11 +207,11 @@ export class BrowserTTSModule extends TTSHandlerModule {
return this.selectDefaultVoice();
}
// Extract language code from locale (e.g., 'en-US' -> 'en')
const langCode = locale.split('-')[0].toLowerCase();
const normalizedLocale = String(locale).replace('_', '-').toLowerCase();
const langCode = normalizedLocale.split('-')[0];
// First try to find a voice that exactly matches the locale
let matchedVoice = this.voices.find(v => v.language && v.language.toLowerCase() === locale.toLowerCase());
let matchedVoice = this.voices.find(v => v.language && v.language.toLowerCase() === normalizedLocale);
// If not found, try to find a voice for the language
if (!matchedVoice && this.voicesByLang[langCode]) {
@@ -220,6 +227,21 @@ export class BrowserTTSModule extends TTSHandlerModule {
// Fall back to default voice
return this.selectDefaultVoice();
}
getPlaybackVolume() {
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
return this.voiceOptions.volume || 1.0;
}
const masterVolume = persistenceManager.getPreference('audio', 'masterVolume', 1.0);
const ttsVolume = persistenceManager.getPreference('audio', 'ttsVolume', 1.0);
const masterEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', true) !== false;
const ttsEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', true) !== false;
const configuredVolume = this.voiceOptions.volume || 1.0;
return Math.max(0, Math.min(1, configuredVolume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
}
/**
* Select a default voice
@@ -342,7 +364,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
utterance.rate = this.voiceOptions.speed || 1.0;
utterance.pitch = this.voiceOptions.pitch || 1.0;
utterance.volume = this.voiceOptions.volume || 1.0;
utterance.volume = this.getPlaybackVolume();
// Set up event handlers
utterance.onstart = this.utteranceHandlers.start;
@@ -484,7 +506,7 @@ export class BrowserTTSModule extends TTSHandlerModule {
* @returns {boolean} - Success status (always false)
*/
speakPreloaded(preloadData, callback = null) {
if (callback) {
if (typeof callback === 'function') {
callback({ success: false, reason: 'not_supported' });
}
return false;
+31 -6
View File
@@ -8,8 +8,9 @@ class ChoiceDisplayModule extends BaseModule {
constructor() {
super('choice-display', 'Choice Display');
this.dependencies = ['socket-client'];
this.dependencies = ['socket-client', 'markup-parser'];
this.socketClient = null;
this.markupParser = null;
this.container = null;
this.choices = [];
this.inputMode = 'text';
@@ -37,12 +38,14 @@ class ChoiceDisplayModule extends BaseModule {
'assignLetters',
'selectChoice',
'getTagValue',
'getTemplateCell'
'getTemplateCell',
'renderChoiceText'
]);
}
async initialize() {
this.socketClient = this.getModule('socket-client');
this.markupParser = this.getModule('markup-parser');
this.setupContainer();
this.addEventListener(document, 'story:choices', (event) => {
@@ -157,9 +160,14 @@ class ChoiceDisplayModule extends BaseModule {
.trim()
.charAt(0)
.toUpperCase();
if (alphabet.includes(explicit) && !reserved.has(explicit)) {
choice.letter = explicit;
reserved.add(explicit);
const keyExplicit = String(choice.letter || this.getTagValue(choice.tags, 'key') || '')
.trim()
.charAt(0)
.toUpperCase();
const reservedLetter = explicit || keyExplicit;
if (alphabet.includes(reservedLetter) && !reserved.has(reservedLetter)) {
choice.letter = reservedLetter;
reserved.add(reservedLetter);
}
});
@@ -225,7 +233,7 @@ class ChoiceDisplayModule extends BaseModule {
const button = document.createElement('button');
button.type = 'button';
button.className = 'choice-button';
button.innerHTML = `<kbd>${choice.letter}</kbd><span>${this.escapeHtml(choice.text)}</span>`;
button.innerHTML = `<kbd>${this.escapeHtml(choice.letter)}</kbd><span>${this.renderChoiceText(choice.text)}</span>`;
button.addEventListener('click', () => this.selectChoice(choice.index));
item.appendChild(button);
list.appendChild(item);
@@ -265,6 +273,23 @@ class ChoiceDisplayModule extends BaseModule {
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
renderChoiceText(text) {
if (!this.markupParser) {
this.markupParser = this.getModule('markup-parser');
}
if (this.markupParser && typeof this.markupParser.markdownToHtml === 'function') {
return this.markupParser.markdownToHtml(String(text || ''));
}
return this.escapeHtml(text)
.replace(/\*\*\*([^*]+?)\*\*\*/g, '<strong><em>$1</em></strong>')
.replace(/___([^_]+?)___/g, '<strong><em>$1</em></strong>')
.replace(/\*\*([^*]+?)\*\*/g, '<strong>$1</strong>')
.replace(/__([^_]+?)__/g, '<strong>$1</strong>')
.replace(/\*([^*\s][^*]*?)\*/g, '<em>$1</em>')
.replace(/_([^_\s][^_]*?)_/g, '<em>$1</em>');
}
}
const choiceDisplay = new ChoiceDisplayModule();
+3 -2
View File
@@ -177,7 +177,7 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
* @param {string} text - Text to generate speech for
* @returns {Promise<Object>} - Audio data object
*/
async generateSpeechAudio(text) {
async generateSpeechAudio(text, options = {}) {
// Don't attempt to call the API if no API key is set or text is empty
if (!text || !this.apiKey) {
return { success: false, reason: 'missing_api_key_or_text' };
@@ -208,7 +208,8 @@ export class ElevenLabsTTSModule extends ApiTTSModuleBase {
'xi-api-key': this.apiKey,
'Accept': 'audio/mpeg'
},
body: JSON.stringify(payload)
body: JSON.stringify(payload),
signal: options.signal
});
if (!response.ok) {
+1 -6
View File
@@ -24,11 +24,6 @@ class GameConfigModule extends BaseModule {
this.reportProgress(20, 'Loading game configuration');
this.config = await this.loadConfig();
const localization = this.getModule('localization');
if (localization && this.config?.locale) {
await localization.applyServerLocale(this.config.locale);
}
this.applyDocumentMetadata();
document.dispatchEvent(new CustomEvent('game:config', {
detail: this.config
@@ -88,7 +83,7 @@ class GameConfigModule extends BaseModule {
}
getLocale() {
return this.config?.locale || 'en_US';
return this.config?.metadata?.language || this.config?.locale || 'en_US';
}
}
+35 -2
View File
@@ -14,6 +14,8 @@ class GameLoopModule extends BaseModule {
// Game state
this.gameState = {
started: false,
startedOnce: false,
ended: false,
canLoad: false,
currentRoom: null,
inventory: [],
@@ -71,6 +73,15 @@ class GameLoopModule extends BaseModule {
document.addEventListener('ui:game:restart', () => this.requestStartGame());
document.addEventListener('ui:game:save', () => this.requestSaveGame());
document.addEventListener('ui:game:load', () => this.requestLoadGame());
document.addEventListener('story:input-mode', (event) => {
if (event.detail !== 'end') {
return;
}
this.gameState.started = false;
this.gameState.ended = true;
this.gameState.canSave = false;
this.updateUIState();
});
}
setupSocketEventListeners() {
@@ -142,6 +153,10 @@ class GameLoopModule extends BaseModule {
]);
this.gameState.started = Boolean(running?.result);
if (this.gameState.started) {
this.gameState.startedOnce = true;
this.gameState.ended = false;
}
this.gameState.canSave = this.gameState.started;
this.gameState.canLoad = Boolean(hasSave?.result);
this.updateUIState();
@@ -177,9 +192,9 @@ class GameLoopModule extends BaseModule {
// Update UI components based on game state
const state = {
canRestart: true,
canSave: Boolean(this.gameState.started),
canSave: Boolean(this.gameState.canSave && this.gameState.started),
canLoad: Boolean(this.gameState.canLoad),
gameStarted: Boolean(this.gameState.started)
gameStarted: Boolean(this.gameState.started || this.gameState.startedOnce || this.gameState.ended)
};
document.body.dataset.gameRunning = state.gameStarted ? 'true' : 'false';
uiController.updateButtonStates(state);
@@ -192,6 +207,11 @@ class GameLoopModule extends BaseModule {
const socketClient = this.getModule('socket-client');
if (!socketClient) return;
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.updateUIState();
await this.resetClientPlaybackAndDisplay();
const storyHistory = this.getModule('story-history');
if (storyHistory && typeof storyHistory.startNewGame === 'function') {
@@ -200,9 +220,14 @@ class GameLoopModule extends BaseModule {
const response = await socketClient.newGame();
if (!response?.success) {
console.error('GameLoop: newGame failed', response);
this.gameState.started = false;
this.gameState.canSave = false;
this.updateUIState();
return;
}
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = Boolean(response.canLoad);
this.updateUIState();
@@ -250,6 +275,12 @@ class GameLoopModule extends BaseModule {
return;
}
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
await this.resetClientPlaybackAndDisplay();
document.dispatchEvent(new CustomEvent('story:history-restoring', {
detail: { active: true, reason: 'load-game' }
@@ -285,6 +316,8 @@ class GameLoopModule extends BaseModule {
}
if (response?.success) {
this.gameState.started = true;
this.gameState.startedOnce = true;
this.gameState.ended = false;
this.gameState.canSave = true;
this.gameState.canLoad = true;
this.updateUIState();
+35 -13
View File
@@ -9,7 +9,7 @@ export class KokoroTTSModule extends TTSHandlerModule {
super('kokoro-tts', 'Kokoro TTS');
// Declare proper dependencies according to architecture principles
this.dependencies = ['persistence-manager', 'localization'];
this.dependencies = ['persistence-manager', 'localization', 'game-config'];
// State
this.iframe = null;
@@ -59,6 +59,13 @@ export class KokoroTTSModule extends TTSHandlerModule {
return false;
}
this.addEventListener(document, 'preference-updated', (event) => {
const { category, key } = event.detail || {};
if (category === 'audio' && ['masterVolume', 'ttsVolume', 'masterVolumeEnabled', 'ttsVolumeEnabled'].includes(key) && this.currentAudio) {
this.currentAudio.volume = this.getPlaybackVolume();
}
});
const ttsEnabled = persistenceManager.getPreference('tts', 'enabled', false);
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler', 'none');
if (!ttsEnabled || preferredHandler !== this.id) {
@@ -256,8 +263,8 @@ export class KokoroTTSModule extends TTSHandlerModule {
}
// Get current locale
const localization = this.getModule('localization');
const locale = localization ? localization.getLocale() : null;
const gameConfig = this.getModule('game-config');
const locale = gameConfig?.getLocale?.() || 'en_US';
// Get preferred voice from preferences
const preferredVoiceId = persistenceManager.getPreference('tts', 'kokoro_voice', '');
@@ -367,6 +374,20 @@ export class KokoroTTSModule extends TTSHandlerModule {
this.setOptions({ volume: Math.max(0, Math.min(1, options.volume)) });
}
}
getPlaybackVolume() {
const persistenceManager = this.getModule('persistence-manager');
if (!persistenceManager) {
return this.options.volume;
}
const masterVolume = persistenceManager.getPreference('audio', 'masterVolume', 1.0);
const ttsVolume = persistenceManager.getPreference('audio', 'ttsVolume', 1.0);
const masterEnabled = persistenceManager.getPreference('audio', 'masterVolumeEnabled', true) !== false;
const ttsEnabled = persistenceManager.getPreference('audio', 'ttsVolumeEnabled', true) !== false;
return Math.max(0, Math.min(1, this.options.volume * (masterEnabled ? masterVolume : 0) * (ttsEnabled ? ttsVolume : 0)));
}
/**
* Get available voices
@@ -431,9 +452,10 @@ export class KokoroTTSModule extends TTSHandlerModule {
* @returns {boolean} - Success status
*/
speakPreloaded(preloadData, callback = null) {
const completionCallback = typeof callback === 'function' ? callback : null;
if (!this.isReady || !preloadData || !preloadData.audioData) {
if (callback) {
callback({ success: false, reason: 'invalid_data' });
if (completionCallback) {
completionCallback({ success: false, reason: 'invalid_data' });
}
return false;
}
@@ -446,22 +468,22 @@ export class KokoroTTSModule extends TTSHandlerModule {
const audioUrl = URL.createObjectURL(audioBlob);
const audio = new Audio(audioUrl);
audio.volume = this.options.volume;
audio.volume = this.getPlaybackVolume();
audio.playbackRate = this.options.rate;
// Set up event handlers
audio.onended = () => {
this.isSpeaking = false;
if (callback) {
callback({ success: true });
if (completionCallback) {
completionCallback({ success: true });
}
URL.revokeObjectURL(audioUrl);
};
audio.onerror = (error) => {
this.isSpeaking = false;
if (callback) {
callback({ success: false, reason: 'playback_error', error });
if (completionCallback) {
completionCallback({ success: false, reason: 'playback_error', error });
}
URL.revokeObjectURL(audioUrl);
};
@@ -475,8 +497,8 @@ export class KokoroTTSModule extends TTSHandlerModule {
}));
}).catch(error => {
this.isSpeaking = false;
if (callback) {
callback({ success: false, reason: 'playback_error', error });
if (completionCallback) {
completionCallback({ success: false, reason: 'playback_error', error });
}
URL.revokeObjectURL(audioUrl);
});
@@ -513,7 +535,7 @@ export class KokoroTTSModule extends TTSHandlerModule {
// Create and play audio
const audio = new Audio(audioUrl);
audio.volume = this.options.volume;
audio.volume = this.getPlaybackVolume();
audio.playbackRate = this.options.rate;
// Set up event handlers
-11
View File
@@ -26,7 +26,6 @@ class LocalizationModule extends BaseModule {
// Bind methods
this.bindMethods([
'setLocale',
'applyServerLocale',
'normalizeLocale',
'getLocale',
'translate',
@@ -145,7 +144,6 @@ class LocalizationModule extends BaseModule {
const persistenceManager = this.getModule('persistence-manager');
if (persistenceManager) {
persistenceManager.updatePreference('app', 'locale', normalizedLocale);
persistenceManager.updatePreference('tts', 'language', normalizedLocale);
if (userInitiated) {
persistenceManager.updatePreference('app', 'localeUserOverride', true);
}
@@ -171,15 +169,6 @@ class LocalizationModule extends BaseModule {
}
}
async applyServerLocale(locale) {
const persistenceManager = this.getModule('persistence-manager');
const userOverride = persistenceManager?.getPreference('app', 'localeUserOverride', false);
if (userOverride) {
return false;
}
return this.setLocale(locale, { userInitiated: false });
}
normalizeLocale(locale) {
const normalized = String(locale || this.defaultLocale).trim().replace('-', '_').toLowerCase();
if (normalized.startsWith('de')) return 'de_DE';
+31 -2
View File
@@ -7,7 +7,7 @@ import { BaseModule } from './base-module.js';
class MarkupParserModule extends BaseModule {
constructor() {
super('markup-parser', 'Markup Parser');
this.dependencies = [];
this.dependencies = ['game-config'];
this.assetRoots = {
images: '/images/',
music: '/music/',
@@ -24,6 +24,9 @@ class MarkupParserModule extends BaseModule {
'markdownToHtml',
'markdownToPlainText',
'smartypants',
'applyLocaleTypography',
'getTypographyLocale',
'normalizeDialogueQuotes',
'escapeHtml',
'normalizeParagraph',
'buildParagraphBlock',
@@ -225,12 +228,38 @@ class MarkupParserModule extends BaseModule {
}
smartypants(text) {
return String(text)
const result = String(text)
.replace(/---/g, '\u2014')
.replace(/--/g, '\u2013')
.replace(/\.\.\./g, '\u2026')
.replace(/(^|[\s([{\u2014])"([^"]*)"/g, '$1\u201c$2\u201d')
.replace(/(^|[\s([{\u2014])'([^']*)'/g, '$1\u2018$2\u2019');
return this.applyLocaleTypography(result);
}
applyLocaleTypography(text) {
const locale = this.getTypographyLocale();
if (locale.startsWith('de')) {
return this.normalizeDialogueQuotes(text);
}
return text;
}
getTypographyLocale() {
const gameConfig = this.getModule('game-config') || window.GameConfig;
const locale = gameConfig?.getLocale?.()
|| gameConfig?.getConfig?.()?.metadata?.language
|| 'en_US';
return String(locale).trim().toLowerCase().replace('_', '-');
}
normalizeDialogueQuotes(text) {
return String(text || '')
.replace(/&(ldquo|bdquo|laquo|raquo);([^&\n]+?)&(rdquo|ldquo|laquo|raquo);/gi, '»$2«')
.replace(/["\u201c\u201e\u201d\u00ab\u00bb]([^"\u201c\u201e\u201d\u00ab\u00bb\n]+?)["\u201c\u201d\u201e\u00ab\u00bb]/g, '»$1«');
}
escapeHtml(text) {
+3 -2
View File
@@ -173,7 +173,7 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
* @param {string} text - Text to generate speech for
* @returns {Promise<Object>} - Audio data object
*/
async generateSpeechAudio(text) {
async generateSpeechAudio(text, options = {}) {
if (!this.isReady || !this.apiKey) {
return { success: false, reason: 'not_ready' };
}
@@ -198,7 +198,8 @@ export class OpenAITTSModule extends ApiTTSModuleBase {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
},
body: JSON.stringify(payload)
body: JSON.stringify(payload),
signal: options.signal
});
if (!response.ok) {
+110 -128
View File
@@ -17,7 +17,8 @@ class OptionsUIModule extends BaseModule {
'persistence-manager',
'localization',
'tts-factory',
'audio-manager'
'audio-manager',
'game-config'
];
// Modal element
@@ -38,6 +39,9 @@ class OptionsUIModule extends BaseModule {
'populateVoices',
'populateLanguages',
'loadPreferences',
'createVolumeControl',
'updateVolumeToggleButtons',
'updateVolumeToggleButton',
'showReloadNotice',
'toggle',
'setupEventListeners',
@@ -170,6 +174,7 @@ class OptionsUIModule extends BaseModule {
// Create body
const body = document.createElement('div');
body.className = 'modal-body';
const localization = this.getModule('localization');
// Create sections
// App Settings Section (Language and Speed)
@@ -193,6 +198,23 @@ class OptionsUIModule extends BaseModule {
}, null, languageContainer);
appSettingsSection.appendChild(languageContainer);
const gameLanguageContainer = document.createElement('div');
gameLanguageContainer.className = 'option-item';
const gameLanguageLabel = document.createElement('label');
gameLanguageLabel.textContent = this.t('options.gameLanguage') + ':';
gameLanguageContainer.appendChild(gameLanguageLabel);
const gameLanguageValue = document.createElement('span');
gameLanguageValue.className = 'game-language-value';
const gameConfig = this.getModule('game-config');
const gameLocale = gameConfig?.getLocale?.() || 'en_US';
gameLanguageValue.textContent = localization?.getLanguageName?.(gameLocale) || gameLocale;
this.elements.gameLanguage = gameLanguageValue;
gameLanguageContainer.appendChild(gameLanguageValue);
appSettingsSection.appendChild(gameLanguageContainer);
// Speed
const speedContainer = document.createElement('div');
@@ -296,125 +318,11 @@ class OptionsUIModule extends BaseModule {
audioTitle.textContent = this.t('options.audio');
audioSection.appendChild(audioTitle);
// Master Volume
const masterVolumeContainer = document.createElement('div');
masterVolumeContainer.className = 'option-item';
const masterVolumeLabel = document.createElement('label');
masterVolumeLabel.textContent = this.t('options.masterVolume') + ':';
masterVolumeContainer.appendChild(masterVolumeLabel);
const masterVolumeValue = document.createElement('span');
masterVolumeValue.className = 'slider-value';
masterVolumeValue.textContent = '100%';
this.elements.masterVolumeValue = masterVolumeValue;
masterVolumeContainer.appendChild(masterVolumeValue);
this.elements.masterVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 100,
'data-pref-bind': 'audio.masterVolume',
'data-pref-transform': 'range:0,1'
}, null, masterVolumeContainer);
// Update displayed value when slider changes
this.elements.masterVolume.addEventListener('input', () => {
this.elements.masterVolumeValue.textContent = `${this.elements.masterVolume.value}%`;
});
audioSection.appendChild(masterVolumeContainer);
// Speech Volume
const ttsVolumeContainer = document.createElement('div');
ttsVolumeContainer.className = 'option-item';
const ttsVolumeLabel = document.createElement('label');
ttsVolumeLabel.textContent = this.t('options.speechVolume') + ':';
ttsVolumeContainer.appendChild(ttsVolumeLabel);
const ttsVolumeValue = document.createElement('span');
ttsVolumeValue.className = 'slider-value';
ttsVolumeValue.textContent = '100%';
this.elements.ttsVolumeValue = ttsVolumeValue;
ttsVolumeContainer.appendChild(ttsVolumeValue);
this.elements.ttsVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 100,
'data-pref-bind': 'audio.ttsVolume',
'data-pref-transform': 'range:0,1'
}, null, ttsVolumeContainer);
// Update displayed value when slider changes
this.elements.ttsVolume.addEventListener('input', () => {
this.elements.ttsVolumeValue.textContent = `${this.elements.ttsVolume.value}%`;
});
audioSection.appendChild(ttsVolumeContainer);
// Music Volume
const musicVolumeContainer = document.createElement('div');
musicVolumeContainer.className = 'option-item';
const musicVolumeLabel = document.createElement('label');
musicVolumeLabel.textContent = this.t('options.musicVolume') + ':';
musicVolumeContainer.appendChild(musicVolumeLabel);
const musicVolumeValue = document.createElement('span');
musicVolumeValue.className = 'slider-value';
musicVolumeValue.textContent = '100%';
this.elements.musicVolumeValue = musicVolumeValue;
musicVolumeContainer.appendChild(musicVolumeValue);
this.elements.musicVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 70,
'data-pref-bind': 'audio.musicVolume',
'data-pref-transform': 'range:0,1'
}, null, musicVolumeContainer);
// Update displayed value when slider changes
this.elements.musicVolume.addEventListener('input', () => {
this.elements.musicVolumeValue.textContent = `${this.elements.musicVolume.value}%`;
});
audioSection.appendChild(musicVolumeContainer);
// SFX Volume
const sfxVolumeContainer = document.createElement('div');
sfxVolumeContainer.className = 'option-item';
const sfxVolumeLabel = document.createElement('label');
sfxVolumeLabel.textContent = this.t('options.sfxVolume') + ':';
sfxVolumeContainer.appendChild(sfxVolumeLabel);
const sfxVolumeValue = document.createElement('span');
sfxVolumeValue.className = 'slider-value';
sfxVolumeValue.textContent = '100%';
this.elements.sfxVolumeValue = sfxVolumeValue;
sfxVolumeContainer.appendChild(sfxVolumeValue);
this.elements.sfxVolume = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: 100,
'data-pref-bind': 'audio.sfxVolume',
'data-pref-transform': 'range:0,1'
}, null, sfxVolumeContainer);
// Update displayed value when slider changes
this.elements.sfxVolume.addEventListener('input', () => {
this.elements.sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`;
});
audioSection.appendChild(sfxVolumeContainer);
audioSection.appendChild(this.createVolumeControl('masterVolume', 'masterVolumeEnabled', 'options.masterVolume', 'options.muteMasterVolume', 'options.unmuteMasterVolume', 100));
audioSection.appendChild(this.createVolumeControl('ttsVolume', 'ttsVolumeEnabled', 'options.speechVolume', 'options.muteSpeechVolume', 'options.unmuteSpeechVolume', 100));
audioSection.appendChild(this.createVolumeControl('musicVolume', 'musicVolumeEnabled', 'options.musicVolume', 'options.muteMusicVolume', 'options.unmuteMusicVolume', 70));
audioSection.appendChild(this.createVolumeControl('sfxVolume', 'sfxVolumeEnabled', 'options.sfxVolume', 'options.muteSfxVolume', 'options.unmuteSfxVolume', 100));
audioSection.appendChild(this.createVolumeControl('musicDuckingAmount', 'musicDuckingEnabled', 'options.musicDucking', 'options.disableMusicDucking', 'options.enableMusicDucking', 30));
body.appendChild(audioSection);
@@ -437,6 +345,69 @@ class OptionsUIModule extends BaseModule {
// Add modal to document
document.body.appendChild(this.modal);
}
createVolumeControl(valueKey, enabledKey, labelKey, muteTitleKey, unmuteTitleKey, defaultPercent) {
const container = document.createElement('div');
container.className = 'option-item volume-option';
const label = document.createElement('label');
label.textContent = this.t(labelKey) + ':';
container.appendChild(label);
const toggle = document.createElement('button');
toggle.type = 'button';
toggle.className = 'volume-toggle';
toggle.dataset.prefCategory = 'audio';
toggle.dataset.prefKey = enabledKey;
toggle.dataset.muteTitleKey = muteTitleKey;
toggle.dataset.unmuteTitleKey = unmuteTitleKey;
toggle.addEventListener('click', () => {
const current = this.getPreference('audio', enabledKey, true) !== false;
this.updatePreference('audio', enabledKey, !current);
this.updateVolumeToggleButton(toggle);
});
container.appendChild(toggle);
const value = document.createElement('span');
value.className = 'slider-value';
value.textContent = `${defaultPercent}%`;
this.elements[`${valueKey}Value`] = value;
container.appendChild(value);
const slider = createUIElement('input', {
type: 'range',
min: 0,
max: 100,
value: defaultPercent,
'data-pref-bind': `audio.${valueKey}`,
'data-pref-transform': 'range:0,1'
}, null, container);
this.elements[valueKey] = slider;
slider.addEventListener('input', () => {
value.textContent = `${slider.value}%`;
});
this.updateVolumeToggleButton(toggle);
return container;
}
updateVolumeToggleButtons() {
if (!this.modal) return;
this.modal.querySelectorAll('.volume-toggle').forEach(button => {
this.updateVolumeToggleButton(button);
});
}
updateVolumeToggleButton(button) {
if (!button) return;
const enabled = this.getPreference(button.dataset.prefCategory, button.dataset.prefKey, true) !== false;
button.classList.toggle('is-muted', !enabled);
button.innerHTML = enabled ? '&#128266;' : '&#128263;';
const titleKey = enabled ? button.dataset.muteTitleKey : button.dataset.unmuteTitleKey;
const title = this.t(titleKey);
button.title = title;
button.setAttribute('aria-label', title);
}
/**
* Create API settings controls
@@ -707,7 +678,7 @@ class OptionsUIModule extends BaseModule {
languageOptions,
'code',
'name',
this.getPreference('app', 'locale', 'en-us')
this.getPreference('app', 'locale', localization.getLocale?.() || 'en_US')
);
}
@@ -883,7 +854,22 @@ class OptionsUIModule extends BaseModule {
audioManager.setSfxVolume(value);
} else if (key === 'ttsVolume') {
audioManager.setTtsVolume(value);
} else if (key === 'masterVolumeEnabled') {
audioManager.setVolumeEnabled('master', value);
} else if (key === 'musicVolumeEnabled') {
audioManager.setVolumeEnabled('music', value);
} else if (key === 'sfxVolumeEnabled') {
audioManager.setVolumeEnabled('sfx', value);
} else if (key === 'ttsVolumeEnabled') {
audioManager.setVolumeEnabled('tts', value);
} else if (key === 'musicDuckingAmount') {
audioManager.setMusicDuckingAmount(value);
} else if (key === 'musicDuckingEnabled') {
audioManager.setMusicDuckingEnabled(value);
}
this.updateVolumeDisplays();
this.updateVolumeToggleButtons();
}
// Handle TTS settings side effects
@@ -905,8 +891,6 @@ class OptionsUIModule extends BaseModule {
ttsFactory.configure({ voice: value });
} else if (key === 'speed') {
ttsFactory.configure({ speed: value });
} else if (key === 'language') {
ttsFactory.configure({ language: value });
} else if (key === 'enabled') {
if (!value) {
ttsFactory.disableAfterCurrentPlayback();
@@ -939,11 +923,6 @@ class OptionsUIModule extends BaseModule {
if (localization) {
localization.setLocale(value);
}
const ttsFactory = this.getModule('tts-factory');
if (ttsFactory) {
ttsFactory.configure({ language: value });
}
this.updatePreference('tts', 'language', value);
}
});
}
@@ -969,6 +948,9 @@ class OptionsUIModule extends BaseModule {
if (this.elements.sfxVolume && this.elements.sfxVolumeValue) {
this.elements.sfxVolumeValue.textContent = `${this.elements.sfxVolume.value}%`;
}
if (this.elements.musicDuckingAmount && this.elements.musicDuckingAmountValue) {
this.elements.musicDuckingAmountValue.textContent = `${this.elements.musicDuckingAmount.value}%`;
}
}
}
+6
View File
@@ -42,9 +42,15 @@ class PersistenceManagerModule extends BaseModule {
},
audio: {
masterVolume: 1.0,
masterVolumeEnabled: true,
ttsVolume: 1.0,
ttsVolumeEnabled: true,
musicVolume: 0.7,
musicVolumeEnabled: true,
sfxVolume: 1.0,
sfxVolumeEnabled: true,
musicDuckingAmount: 0.3,
musicDuckingEnabled: true,
},
app: {
locale: null,
+134 -7
View File
@@ -4,6 +4,8 @@
*/
import { BaseModule } from './base-module.js';
const TTS_GENERATION_TIMEOUT_MS = 60000;
class SentenceQueueModule extends BaseModule {
constructor() {
super('sentence-queue', 'Sentence Queue');
@@ -22,6 +24,8 @@ class SentenceQueueModule extends BaseModule {
this.inputMode = 'text';
this.lastContinueAt = 0;
this.pauseBeforeNextReason = null;
this.ttsGenerationTimeoutMs = TTS_GENERATION_TIMEOUT_MS;
this.generationRequests = new Map();
// Bind methods
this.bindMethods([
@@ -34,6 +38,11 @@ class SentenceQueueModule extends BaseModule {
'getCacheKey',
'getPreparedSentence',
'prefetchAhead',
'prepareSpeechMetadata',
'normalizeTtsText',
'runTtsPreloadWithTimeout',
'cancelBlockingGeneration',
'cancelGenerationRequests',
'isSpeechItem',
'getMediaPauseSeconds',
'readFirstFiniteNumber',
@@ -86,6 +95,7 @@ class SentenceQueueModule extends BaseModule {
this.addEventListener(document, 'ui:command', (event) => {
if (event.detail?.type === 'continue') {
this.lastContinueAt = performance.now();
this.cancelBlockingGeneration('user-fast-forward');
}
});
return true;
@@ -200,13 +210,21 @@ class SentenceQueueModule extends BaseModule {
* @param {string} text - Text to prepare speech for
* @returns {Promise<Object>} - Speech metadata object
*/
async prepareSpeechMetadata(text) {
async prepareSpeechMetadata(text, context = {}) {
const ttsFactory = this.getModule('tts-factory');
if (!ttsFactory) {
throw new Error("TTS dependencies not found");
}
const ttsText = this.normalizeTtsText(text);
if (!ttsText) {
console.warn('SentenceQueue: Empty TTS text after normalization, using estimated silent timing', {
sentenceId: context.sentenceId || null
});
return this.estimateSpeechDuration(text);
}
// Check if TTS is enabled via active handler
const activeHandler = ttsFactory.getActiveHandler();
const isTtsEnabled = activeHandler !== null;
@@ -218,20 +236,28 @@ class SentenceQueueModule extends BaseModule {
try {
// Preload the speech to get metadata
const result = await ttsFactory.preloadSpeech(text);
const result = await this.runTtsPreloadWithTimeout(ttsFactory, ttsText, context);
if (!result.success) {
console.warn("SentenceQueue: Speech preload failed, using estimated duration");
console.warn("SentenceQueue: Speech preload failed, using estimated duration", {
reason: result.reason || 'unknown',
sentenceId: context.sentenceId || null,
textPreview: ttsText.slice(0, 80)
});
return this.estimateSpeechDuration(text);
}
// Create a speech metadata object
return {
text: text,
text: ttsText,
duration: result.duration || this.estimateSpeechDuration(text).duration,
handler: ttsFactory.getActiveHandler() ? ttsFactory.getActiveHandler().id : null,
audioData: result.audioData || null,
play: async () => {
return ttsFactory.speak(text);
if (result.audioData && typeof ttsFactory.speakPreloaded === 'function') {
return ttsFactory.speakPreloaded(result);
}
return ttsFactory.speak(ttsText);
},
stop: () => {
return ttsFactory.stop();
@@ -243,6 +269,94 @@ class SentenceQueueModule extends BaseModule {
return this.estimateSpeechDuration(text);
}
}
normalizeTtsText(text) {
return String(text || '')
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
runTtsPreloadWithTimeout(ttsFactory, text, context = {}) {
const sentenceId = context.sentenceId || context.id || `tts-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const requestId = `${sentenceId}:${context.prefetch ? 'prefetch' : 'blocking'}:${Date.now()}`;
const controller = new AbortController();
const startedAt = performance.now();
return new Promise((resolve) => {
let settled = false;
const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timeoutId);
this.generationRequests.delete(requestId);
resolve(result);
};
const timeoutId = setTimeout(() => {
console.warn('SentenceQueue: TTS generation timed out; continuing without audio', {
sentenceId,
timeoutMs: this.ttsGenerationTimeoutMs,
textPreview: text.slice(0, 120)
});
controller.abort('tts-generation-timeout');
finish({ success: false, reason: 'tts_generation_timeout', timedOut: true });
}, this.ttsGenerationTimeoutMs);
this.generationRequests.set(requestId, {
controller,
sentenceId,
blocking: context.blocking !== false,
startedAt,
textPreview: text.slice(0, 120),
finish
});
Promise.resolve(ttsFactory.preloadSpeech(text, { signal: controller.signal }))
.then(result => finish(result || { success: false, reason: 'empty_tts_result' }))
.catch(error => {
if (controller.signal.aborted) {
console.warn('SentenceQueue: TTS generation cancelled; continuing without audio', {
sentenceId,
reason: controller.signal.reason || 'aborted',
elapsedMs: Math.round(performance.now() - startedAt)
});
finish({ success: false, reason: 'tts_generation_aborted', error });
} else {
console.warn('SentenceQueue: TTS generation failed; continuing without audio', {
sentenceId,
error
});
finish({ success: false, reason: 'tts_generation_error', error });
}
});
});
}
cancelBlockingGeneration(reason = 'cancelled') {
this.cancelGenerationRequests(reason, request => request.blocking === true);
}
cancelGenerationRequests(reason = 'cancelled', predicate = () => true) {
for (const [requestId, request] of this.generationRequests.entries()) {
if (!predicate(request)) continue;
console.warn('SentenceQueue: Cancelling TTS generation request', {
requestId,
sentenceId: request.sentenceId,
reason,
elapsedMs: Math.round(performance.now() - request.startedAt),
textPreview: request.textPreview
});
try {
request.controller.abort(reason);
} catch (error) {
console.warn('SentenceQueue: Failed to abort TTS generation request', { requestId, error });
}
if (typeof request.finish === 'function') {
request.finish({ success: false, reason: 'tts_generation_cancelled' });
}
}
}
/**
* Estimate speech duration based on character count
@@ -314,7 +428,12 @@ class SentenceQueueModule extends BaseModule {
await audioManager.preloadMediaCues(metadata.cueMarkers || []);
}
const ttsData = await this.prepareSpeechMetadata(text);
const ttsData = await this.prepareSpeechMetadata(text, {
sentenceId: id,
blockId: metadata.blockId ?? null,
turnId: metadata.turnId ?? null,
blocking: true
});
console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`);
@@ -557,7 +676,14 @@ class SentenceQueueModule extends BaseModule {
console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index });
const promise = (this.isSpeechItem(nextItem)
? this.prepareSpeechMetadata(nextItem.text || '')
? this.prepareSpeechMetadata(nextItem.text || '', {
sentenceId: nextItem.id,
blockId: nextItem.blockId ?? null,
turnId: nextItem.turnId ?? null,
queueIndex: index,
prefetch: true,
blocking: false
})
: Promise.resolve(null))
.then(() => {
console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index });
@@ -781,6 +907,7 @@ class SentenceQueueModule extends BaseModule {
clear() {
this.sentenceQueue = [];
this.isProcessing = false;
this.cancelGenerationRequests('sentence-queue-cleared');
this.prefetchingSpeech.clear();
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
detail: { reason: 'sentence-queue-cleared' }
+16 -2
View File
@@ -214,10 +214,20 @@ class SocketClientModule extends BaseModule {
this.receivedParagraphCounter = 0;
}
if (Array.isArray(data.globalTags) && data.globalTags.length > 0) {
const globalTags = Array.isArray(data.globalTags) ? data.globalTags : [];
const endState = data.gameState?.endState || null;
if (endState && !globalTags.some((tag) => tag?.key === 'score' || tag?.key === 'error')) {
globalTags.push({
key: endState.type === 'error' ? 'error' : 'score',
value: endState.message || ''
});
}
if (globalTags.length > 0) {
document.dispatchEvent(new CustomEvent('story:global-tags', {
detail: data.globalTags
detail: globalTags
}));
this.dispatchTurnTags(globalTags, null);
}
document.dispatchEvent(new CustomEvent('story:turn-start', {
@@ -245,6 +255,10 @@ class SocketClientModule extends BaseModule {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'choice-only-turn', turnId }
}));
} else if (turnBlocks.length === 0 && inputMode === 'end') {
document.dispatchEvent(new CustomEvent('story:process-state', {
detail: { state: 'ready', reason: 'empty-end-turn', turnId }
}));
}
}
+37 -26
View File
@@ -13,8 +13,7 @@ class TextProcessorModule extends BaseModule {
this.hyphenatorReady = false;
this.locale = 'en-us';
// Add localization as a dependency
this.dependencies = ['localization'];
this.dependencies = ['localization', 'game-config'];
// Bind methods using parent's bindMethods utility
this.bindMethods([
@@ -24,9 +23,11 @@ class TextProcessorModule extends BaseModule {
'isHyphenationAvailable',
'hyphenate',
'setLocale',
'handleLocaleChanged',
'loadHyphenopolyLoader',
'normalizeHyphenationLocale'
'normalizeHyphenationLocale',
'applyLocaleTypography',
'getTypographyLocale',
'normalizeDialogueQuotes'
]);
}
@@ -38,18 +39,15 @@ class TextProcessorModule extends BaseModule {
try {
this.reportProgress(10, "Initializing text processor");
// Get locale from Localization module if available
const localizationModule = this.getModule('localization');
if (!localizationModule) {
console.error("Localization module not found, required dependency missing");
this.reportProgress(100, "Text processor initialization failed - missing localization");
return false;
}
this.locale = localizationModule.getLocale();
// Register for locale changes using the proper event pattern
this.addEventListener(document, 'locale-changed', this.handleLocaleChanged);
const gameConfig = this.getModule('game-config');
this.locale = gameConfig?.getLocale?.() || 'en_US';
this.addEventListener(document, 'game:config', (event) => {
const gameLocale = event.detail?.metadata?.language || event.detail?.locale;
if (gameLocale) {
this.setLocale(gameLocale);
}
});
this.reportProgress(30, `Locale set to ${this.locale}`);
@@ -92,16 +90,6 @@ class TextProcessorModule extends BaseModule {
}
}
/**
* Handle locale changed event
* @param {CustomEvent} event - The locale-changed event
*/
handleLocaleChanged(event) {
if (event && event.detail && event.detail.locale) {
this.setLocale(event.detail.locale);
}
}
/**
* Set the locale for the text processor
* @param {string} locale - The locale to set
@@ -299,6 +287,10 @@ class TextProcessorModule extends BaseModule {
result = this.smartyPants(result);
}
if (opts.smartypants) {
result = this.applyLocaleTypography(result);
}
// Apply hyphenation if available and requested
if (opts.hyphenate && this.isHyphenationAvailable()) {
result = this.hyphenate(result, opts.hyphenSelector);
@@ -306,6 +298,25 @@ class TextProcessorModule extends BaseModule {
return result;
}
applyLocaleTypography(text) {
const locale = this.getTypographyLocale();
if (locale.startsWith('de')) {
return this.normalizeDialogueQuotes(text);
}
return text;
}
getTypographyLocale() {
return String(this.locale || 'en_US').trim().toLowerCase().replace('_', '-');
}
normalizeDialogueQuotes(text) {
return String(text || '')
.replace(/&(ldquo|bdquo|laquo|raquo);([^&\n]+?)&(rdquo|ldquo|laquo|raquo);/gi, '»$2«')
.replace(/["\u201c\u201e\u201d\u00ab\u00bb]([^"\u201c\u201e\u201d\u00ab\u00bb\n]+?)["\u201c\u201d\u201e\u00ab\u00bb]/g, '»$1«');
}
}
// Create the singleton instance
+22 -15
View File
@@ -14,6 +14,7 @@ class TTSFactoryModule extends BaseModule {
this.dependencies = [
'persistence-manager',
'localization',
'game-config',
'browser-tts', // Browser TTS handler
'kokoro-tts', // Kokoro TTS handler
'elevenlabs-tts',// ElevenLabs TTS handler
@@ -24,7 +25,7 @@ class TTSFactoryModule extends BaseModule {
this.activeHandler = null;
this.ttsAvailable = false;
this.speed = 1; // Speech speed multiplier. 1.0 is normal speed.
this.language = 'en-us';
this.language = 'en_US';
this.voice = '';
this.volume = 1.0;
@@ -224,9 +225,10 @@ class TTSFactoryModule extends BaseModule {
}
});
document.addEventListener('locale-changed', (event) => {
if (event.detail?.locale) {
this.configure({ language: event.detail.locale });
document.addEventListener('game:config', (event) => {
const language = event.detail?.metadata?.language || event.detail?.locale;
if (language) {
this.configure({ language, persistLanguage: false });
}
});
@@ -403,7 +405,7 @@ class TTSFactoryModule extends BaseModule {
'preferred_handler': 'none', // Development default: TTS disabled
'enabled': false, // TTS disabled by default
'voice': '', // Empty default - will be selected based on handler
'language': 'en-US', // Default language
'language': 'en_US', // Legacy stored value; game metadata now owns active TTS language
'volume': 1.0, // Default volume
'elevenlabs_api_key': '', // Empty API key by default
'elevenlabs_api_url': 'https://api.elevenlabs.io/v1', // Default ElevenLabs API URL
@@ -433,7 +435,8 @@ class TTSFactoryModule extends BaseModule {
// Load other preferences we need for initialization
const preferredHandler = persistenceManager.getPreference('tts', 'preferred_handler');
console.log(`TTS Factory: Loaded preferred handler: ${preferredHandler || 'none'}`);
this.language = persistenceManager.getPreference('tts', 'language', defaults.language);
const gameConfig = this.getModule('game-config');
this.language = String(gameConfig?.getLocale?.() || defaults.language).replace('_', '-').toLowerCase();
this.voice = persistenceManager.getPreference('tts', 'voice', defaults.voice);
this.volume = persistenceManager.getPreference('tts', 'volume', defaults.volume);
@@ -698,7 +701,7 @@ class TTSFactoryModule extends BaseModule {
if (persistenceManager) {
persistenceManager.updatePreference('tts', 'preferred_handler', id);
this.voice = persistenceManager.getPreference('tts', 'voice', this.voice || '');
this.language = persistenceManager.getPreference('tts', 'language', this.language || 'en-us');
this.language = String(this.getModule('game-config')?.getLocale?.() || this.language || 'en_US').replace('_', '-').toLowerCase();
this.speed = persistenceManager.getPreference('tts', 'speed', this.speed || 1.0);
}
@@ -788,7 +791,7 @@ class TTSFactoryModule extends BaseModule {
// Not cached, generate and cache
if (typeof handler.preloadSpeech === 'function') {
console.log(`TTS Factory: Generating and caching speech for hash ${hash}`);
const preloadData = await handler.preloadSpeech(text);
const preloadData = await handler.preloadSpeech(text, options);
if (preloadData && preloadData.success) {
// Cache the speech
await this.cacheSpeech(hash, preloadData.audioData, preloadData.duration);
@@ -822,7 +825,7 @@ class TTSFactoryModule extends BaseModule {
* @param {number} [priority=5] - Priority for preloading (1-10, higher is more important)
* @returns {Promise<Object>} - Preloaded speech data
*/
async preloadSpeech(text, priority = 5) {
async preloadSpeech(text, options = {}) {
// Check if we have an active handler
if (!this.activeHandler || !this.ttsAvailable) {
console.warn('TTS Factory: Cannot preload speech - no active handler or TTS not available');
@@ -855,7 +858,7 @@ class TTSFactoryModule extends BaseModule {
// If the handler has a preloadSpeech method, use it
if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') {
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text);
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text, options);
// Cache the generated speech data (extract audioData from result object)
if (preloadData && preloadData.audioData) {
@@ -1169,9 +1172,9 @@ class TTSFactoryModule extends BaseModule {
}
if (typeof options.language === 'string' && options.language) {
this.language = options.language.toLowerCase();
this.language = options.language.replace('_', '-').toLowerCase();
voiceOptions.language = this.language;
if (persistenceManager) {
if (persistenceManager && options.persistLanguage === true) {
persistenceManager.updatePreference('tts', 'language', this.language);
}
}
@@ -1215,7 +1218,7 @@ class TTSFactoryModule extends BaseModule {
* @param {string} text - Text to preload
* @returns {Promise<Object>} - Resolves with preloaded speech data
*/
async preloadSpeech(text) {
async preloadSpeech(text, options = {}) {
if (!this.activeHandler) {
console.warn("TTS Factory: No active TTS handler for preload");
return null;
@@ -1243,7 +1246,7 @@ class TTSFactoryModule extends BaseModule {
// If the handler has a preloadSpeech method, use it
if (typeof this.handlers[this.activeHandler].preloadSpeech === 'function') {
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text);
const preloadData = await this.handlers[this.activeHandler].preloadSpeech(text, options);
// Cache the generated speech data (extract audioData from result object)
if (preloadData && preloadData.audioData) {
@@ -1313,7 +1316,11 @@ class TTSFactoryModule extends BaseModule {
// If the handler has a speakPreloaded method, use it
if (typeof this.handlers[this.activeHandler].speakPreloaded === 'function') {
return await this.handlers[this.activeHandler].speakPreloaded(preloadData, options);
return await this.handlers[this.activeHandler].speakPreloaded(preloadData, result => {
document.dispatchEvent(new CustomEvent('tts:speechCompleted', {
detail: { success: result?.success === true, error: result?.error }
}));
});
} else {
console.warn(`TTS Factory: Handler ${this.activeHandler} does not support speaking preloaded data`);
return false;
+279 -2
View File
@@ -48,6 +48,10 @@ class UIDisplayHandlerModule extends BaseModule {
this.lastManualScrollAt = 0;
this.layoutFlowLine = 0;
this.layoutExclusions = [];
this.notificationQueue = [];
this.notificationActive = false;
this.pendingTerminalNotifications = [];
this.latestInputMode = 'text';
// Resources to preload
this.cssPath = '/css/style.css';
@@ -121,7 +125,19 @@ class UIDisplayHandlerModule extends BaseModule {
'measureText',
'loadCSS',
'showChoices',
'preloadImages'
'preloadImages',
'createCreditsDialog',
'openCreditsDialog',
'closeCreditsDialog',
'loadCreditsText',
'createNotificationDialog',
'handleStoryTag',
'getTagMessage',
'showNotification',
'displayNextNotification',
'queueTerminalNotification',
'flushTerminalNotifications',
'closeNotification'
]);
console.log('UIDisplayHandler: Constructor initialized');
@@ -173,6 +189,15 @@ class UIDisplayHandlerModule extends BaseModule {
this.addEventListener(document, 'story:history-updated', (event) => {
this.updateStoryScrollbar(event.detail || {});
});
this.addEventListener(document, 'story:tag', (event) => {
this.handleStoryTag(event.detail);
});
this.addEventListener(document, 'story:turn-start', () => {
this.latestInputMode = 'text';
});
this.addEventListener(document, 'story:input-mode', (event) => {
this.latestInputMode = event.detail || 'text';
});
this.addEventListener(document, 'wheel', this.handleHistoryWheel, { passive: false });
this.addEventListener(document, 'keydown', (event) => {
const tagName = String(event.target?.tagName || '').toLowerCase();
@@ -213,6 +238,9 @@ class UIDisplayHandlerModule extends BaseModule {
? this.t('title.continueHint')
: this.t('title.fastForwardHint');
}
if (state === 'ready' && this.latestInputMode === 'end') {
this.flushTerminalNotifications();
}
});
if (window.ResizeObserver && this.paragraphContainer) {
@@ -472,6 +500,9 @@ class UIDisplayHandlerModule extends BaseModule {
lighting.id = 'lighting';
document.body.appendChild(lighting);
}
this.createCreditsDialog();
this.createNotificationDialog();
console.log('UIDisplayHandler: All containers initialized');
this.applyGameConfig(this.gameConfig?.getConfig?.());
@@ -497,7 +528,27 @@ class UIDisplayHandlerModule extends BaseModule {
metadata.version ? this.t('title.version', { version: metadata.version }) : '',
metadata.copyright || ''
].filter(Boolean);
legalElement.textContent = items.join(' · ');
legalElement.innerHTML = '';
const legalText = document.createElement('span');
legalText.id = 'game_legal_text';
legalText.textContent = items.join(' | ');
legalElement.appendChild(legalText);
const creditsButton = document.createElement('button');
creditsButton.id = 'credits_button';
creditsButton.type = 'button';
creditsButton.textContent = this.t('credits.button');
creditsButton.title = this.t('credits.buttonTitle');
creditsButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.openCreditsDialog();
});
if (items.length > 0) {
legalElement.appendChild(document.createTextNode(' | '));
}
legalElement.appendChild(creditsButton);
}
}
@@ -522,6 +573,9 @@ class UIDisplayHandlerModule extends BaseModule {
setText('options', 'topbar.options');
setText('remark_text', 'title.fastForwardHint');
setText('start_prompt', 'title.startPrompt');
setText('credits_dialog_title', 'credits.title');
setText('credits_close', 'credits.close');
setText('story_popup_ok', 'popup.ok');
setTitle('speech', 'topbar.speechTitle');
setTitle('autoplay', 'topbar.autoplayTitle');
setTitle('rewind', 'topbar.newGameTitle');
@@ -533,6 +587,224 @@ class UIDisplayHandlerModule extends BaseModule {
if (input) input.setAttribute('placeholder', this.t('input.placeholder'));
this.applyGameConfig(this.gameConfig?.getConfig?.());
}
createCreditsDialog() {
if (document.getElementById('credits_modal')) {
return;
}
const modal = document.createElement('div');
modal.id = 'credits_modal';
modal.className = 'credits-modal';
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="credits-dialog" role="dialog" aria-modal="true" aria-labelledby="credits_dialog_title">
<div class="credits-dialog-header">
<h2 id="credits_dialog_title"></h2>
<button type="button" id="credits_close"></button>
</div>
<div class="credits-logo-row" aria-label="Credits links">
<a href="https://openai.com/" target="_blank" rel="noreferrer"><img src="https://cdn.simpleicons.org/openai/2b2218" alt="OpenAI"></a>
<a href="https://www.inklestudios.com/ink/" target="_blank" rel="noreferrer" class="credits-wordmark">ink</a>
<a href="https://mnater.github.io/Hyphenopoly/" target="_blank" rel="noreferrer" class="credits-wordmark">Hyphenopoly</a>
<a href="https://github.com/hexgrad/kokoro" target="_blank" rel="noreferrer" class="credits-wordmark">Kokoro</a>
<a href="https://suno.com/" target="_blank" rel="noreferrer" class="credits-wordmark">Suno</a>
</div>
<pre id="credits_content" class="credits-content"></pre>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (event) => {
if (event.target === modal) {
this.closeCreditsDialog();
}
});
const closeButton = document.getElementById('credits_close');
if (closeButton) {
closeButton.addEventListener('click', () => this.closeCreditsDialog());
}
}
async openCreditsDialog() {
const modal = document.getElementById('credits_modal');
const content = document.getElementById('credits_content');
if (!modal || !content) {
return;
}
modal.classList.add('visible');
modal.setAttribute('aria-hidden', 'false');
if (!content.dataset.loaded) {
content.textContent = this.t('credits.loading');
content.textContent = await this.loadCreditsText();
content.dataset.loaded = 'true';
}
}
closeCreditsDialog() {
const modal = document.getElementById('credits_modal');
if (!modal) {
return;
}
modal.classList.remove('visible');
modal.setAttribute('aria-hidden', 'true');
}
async loadCreditsText() {
try {
const response = await fetch('/THIRD_PARTY_NOTICES.md', { cache: 'no-cache' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return await response.text();
} catch (error) {
console.warn('UIDisplayHandler: Failed to load credits notices', error);
return this.t('credits.loadFailed');
}
}
createNotificationDialog() {
if (document.getElementById('story_popup_modal')) {
return;
}
const modal = document.createElement('div');
modal.id = 'story_popup_modal';
modal.className = 'story-popup-modal';
modal.setAttribute('aria-hidden', 'true');
modal.innerHTML = `
<div class="story-popup-dialog" role="dialog" aria-modal="true" aria-labelledby="story_popup_title">
<h2 id="story_popup_title"></h2>
<div id="story_popup_message"></div>
<button type="button" id="story_popup_ok"></button>
</div>
`;
document.body.appendChild(modal);
modal.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (event.target === modal) {
this.closeNotification();
}
});
modal.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
const okButton = document.getElementById('story_popup_ok');
if (okButton) {
okButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
this.closeNotification();
});
}
}
handleStoryTag(tag) {
const key = String(tag?.key || '').toLowerCase();
if (!['score', 'error', 'achievement', 'alert'].includes(key)) {
return;
}
const message = this.getTagMessage(tag);
if (key === 'score') {
this.queueTerminalNotification(
'ending',
this.t('popup.endingTitle'),
message || this.t('popup.defaultEnding')
);
} else if (key === 'error') {
this.queueTerminalNotification(
'error',
this.t('popup.errorTitle'),
message || this.t('popup.defaultError')
);
} else if (key === 'achievement') {
this.showNotification(
'achievement',
this.t('popup.achievementTitle'),
message || this.t('popup.defaultAchievement')
);
} else if (key === 'alert') {
this.showNotification(
'alert',
this.t('popup.alertTitle'),
message || this.t('popup.defaultAlert')
);
}
}
getTagMessage(tag) {
return [tag?.value, tag?.param]
.map((part) => String(part || '').trim())
.filter(Boolean)
.join('\n');
}
showNotification(kind, title, message) {
this.notificationQueue.push({ kind, title, message });
this.displayNextNotification();
}
queueTerminalNotification(kind, title, message) {
this.pendingTerminalNotifications.push({ kind, title, message });
if (this.latestInputMode === 'end') {
this.flushTerminalNotifications();
}
}
flushTerminalNotifications() {
if (this.pendingTerminalNotifications.length === 0) {
return;
}
this.pendingTerminalNotifications.splice(0).forEach((notification) => {
this.showNotification(notification.kind, notification.title, notification.message);
});
}
displayNextNotification() {
if (this.notificationActive || this.notificationQueue.length === 0) {
return;
}
const next = this.notificationQueue.shift();
const modal = document.getElementById('story_popup_modal');
const title = document.getElementById('story_popup_title');
const message = document.getElementById('story_popup_message');
const okButton = document.getElementById('story_popup_ok');
if (!modal || !title || !message) {
return;
}
modal.dataset.kind = next.kind;
title.textContent = next.title;
message.textContent = next.message;
if (okButton) {
okButton.textContent = this.t('popup.ok');
setTimeout(() => okButton.focus(), 0);
}
this.notificationActive = true;
modal.classList.add('visible');
modal.setAttribute('aria-hidden', 'false');
}
closeNotification() {
const modal = document.getElementById('story_popup_modal');
if (!modal) {
this.notificationActive = false;
return;
}
modal.classList.remove('visible');
modal.setAttribute('aria-hidden', 'true');
this.notificationActive = false;
setTimeout(() => this.displayNextNotification(), 0);
}
/**
* Measure text width using canvas
@@ -1927,6 +2199,11 @@ class UIDisplayHandlerModule extends BaseModule {
this.container.appendChild(this.paragraphContainer);
}
this.renderedItems = [];
this.notificationQueue = [];
this.pendingTerminalNotifications = [];
this.notificationActive = false;
document.getElementById('story_popup_modal')?.classList.remove('visible');
document.getElementById('story_popup_modal')?.setAttribute('aria-hidden', 'true');
this.historyWindowStartId = 1;
this.historyWindowEndId = 0;
this.storyTopLine = 0;
+32 -5
View File
@@ -1,8 +1,8 @@
{
"title.byAuthor": "von {{author}}",
"title.byAuthor": "{{author}}",
"title.version": "Version {{version}}",
"title.fastForwardHint": "auf die Seite klicken oder Leertaste druecken, um die Textanimation vorzuspulen",
"title.continueHint": "auf die Seite klicken oder Leertaste druecken, um fortzufahren",
"title.fastForwardHint": "auf die Seite klicken oder Leertaste drücken, um die Textanimation vorzuspulen",
"title.continueHint": "auf die Seite klicken oder Leertaste drücken, um fortzufahren",
"title.startPrompt": "Klicke auf Neues Spiel oder Laden, um das Spiel zu starten",
"topbar.speech": "Sprache",
"topbar.autoplay": "Auto",
@@ -21,7 +21,8 @@
"options.title": "Optionen",
"options.close": "Schliessen",
"options.applicationSettings": "Anwendung",
"options.language": "Sprache",
"options.language": "UI-Sprache",
"options.gameLanguage": "Spieltext-Sprache",
"options.speech": "Sprachausgabe",
"options.enableSpeech": "Sprachausgabe aktivieren",
"options.provider": "Anbieter",
@@ -33,8 +34,34 @@
"options.speechVolume": "Sprachlautstaerke",
"options.musicVolume": "Musiklautstaerke",
"options.sfxVolume": "Effektlautstaerke",
"options.musicDucking": "Musikabsenkung",
"options.muteMasterVolume": "Gesamtaudio ausschalten",
"options.unmuteMasterVolume": "Gesamtaudio einschalten",
"options.muteSpeechVolume": "Sprachausgabe ausschalten",
"options.unmuteSpeechVolume": "Sprachausgabe einschalten",
"options.muteMusicVolume": "Musik ausschalten",
"options.unmuteMusicVolume": "Musik einschalten",
"options.muteSfxVolume": "Soundeffekte ausschalten",
"options.unmuteSfxVolume": "Soundeffekte einschalten",
"options.disableMusicDucking": "Musikabsenkung ausschalten",
"options.enableMusicDucking": "Musikabsenkung einschalten",
"options.elevenLabsSettings": "ElevenLabs API-Einstellungen",
"options.openAiSettings": "OpenAI API-Einstellungen",
"options.apiKey": "API-Schluessel",
"options.apiUrl": "API-URL"
"options.apiUrl": "API-URL",
"credits.button": "Credits",
"credits.buttonTitle": "Credits und Lizenzen anzeigen",
"credits.title": "Credits und Lizenzen",
"credits.close": "Schliessen",
"credits.loading": "Credits werden geladen...",
"credits.loadFailed": "Credits und Lizenzhinweise konnten nicht geladen werden.",
"popup.ok": "OK",
"popup.endingTitle": "Ende erreicht",
"popup.errorTitle": "Spiel beendet",
"popup.achievementTitle": "Errungenschaft",
"popup.alertTitle": "Hinweis",
"popup.defaultEnding": "Du hast ein Ende erreicht.",
"popup.defaultError": "Das Spiel wurde wegen eines nicht behebbaren Fehlers beendet.",
"popup.defaultAchievement": "Errungenschaft freigeschaltet.",
"popup.defaultAlert": "Hinweis"
}
+30 -3
View File
@@ -1,5 +1,5 @@
{
"title.byAuthor": "by {{author}}",
"title.byAuthor": "{{author}}",
"title.version": "Version {{version}}",
"title.fastForwardHint": "click on page or press spacebar to fast forward text animation",
"title.continueHint": "click on page or press spacebar to continue",
@@ -21,7 +21,8 @@
"options.title": "Options",
"options.close": "Close",
"options.applicationSettings": "Application Settings",
"options.language": "Language",
"options.language": "UI Language",
"options.gameLanguage": "Game Text Language",
"options.speech": "Speech",
"options.enableSpeech": "Enable text to speech",
"options.provider": "Provider",
@@ -33,8 +34,34 @@
"options.speechVolume": "Speech Volume",
"options.musicVolume": "Music Volume",
"options.sfxVolume": "Sound Effects Volume",
"options.musicDucking": "Music Ducking",
"options.muteMasterVolume": "Disable master audio",
"options.unmuteMasterVolume": "Enable master audio",
"options.muteSpeechVolume": "Disable speech audio",
"options.unmuteSpeechVolume": "Enable speech audio",
"options.muteMusicVolume": "Disable music audio",
"options.unmuteMusicVolume": "Enable music audio",
"options.muteSfxVolume": "Disable sound effects",
"options.unmuteSfxVolume": "Enable sound effects",
"options.disableMusicDucking": "Disable music ducking",
"options.enableMusicDucking": "Enable music ducking",
"options.elevenLabsSettings": "ElevenLabs API Settings",
"options.openAiSettings": "OpenAI API Settings",
"options.apiKey": "API Key",
"options.apiUrl": "API URL"
"options.apiUrl": "API URL",
"credits.button": "credits",
"credits.buttonTitle": "Show credits and third-party licenses",
"credits.title": "Credits and Licenses",
"credits.close": "Close",
"credits.loading": "Loading credits...",
"credits.loadFailed": "Credits and license notices could not be loaded.",
"popup.ok": "OK",
"popup.endingTitle": "Ending reached",
"popup.errorTitle": "Game ended",
"popup.achievementTitle": "Achievement",
"popup.alertTitle": "Hint",
"popup.defaultEnding": "You reached an ending.",
"popup.defaultError": "The game ended because of an unrecoverable error.",
"popup.defaultAchievement": "Achievement unlocked.",
"popup.defaultAlert": "Hint"
}
+2
View File
@@ -23,6 +23,8 @@ Supported playback flags:
Music volume is controlled by master volume and music volume in the options menu. While TTS is playing, music is ducked to 70% of its configured volume and restored when TTS playback is idle.
The ducking amount is configurable in the options menu and can be disabled with the music-ducking mute toggle. Music state is saved with browser savegames when a track is active, including the current playback position, and restored with a fade-in on load.
Browsers require a user interaction before audio can play, so music should begin after `new game` or `load`, not during passive page load.
Document third-party source and license information here or next to the file.
+10
View File
@@ -7,6 +7,7 @@ Use a sound effect story tag:
```text
#sfx[squeaky-door.ogg]
#sfx[church-bells.ogg](max=8 fade fade-duration=2)
The old door opens into the dark.
```
@@ -16,6 +17,15 @@ Supported browser-friendly formats are recommended: `.ogg`, `.mp3`, and `.wav`.
Sound effect loudness is controlled by the master volume and sound effects volume sliders in the options menu.
Supported timing/end options:
- `max=`, `duration=`, `max-duration=`, `limit=`, `stop-after=`: stop after this many seconds.
- `fade-after=`: fade after this many seconds.
- bare seconds such as `4s`: shorthand for a maximum duration.
- `fade`, `fadeout`, `fade-out`, or `mode=fade`: fade out when the limit is reached.
- `stop`, `cut`, `halt`, or `mode=stop`: stop immediately when the limit is reached.
- `fade-duration=` or `fade-time=`: fade length in seconds.
Document third-party source and license information here or next to the file.
Current assets:
+3
View File
@@ -9,6 +9,7 @@ export interface GameMetadata {
subtitle?: string;
version?: string;
copyright?: string;
language?: string;
}
export interface GamePaths {
@@ -52,6 +53,7 @@ function fallbackConfig(engine: EngineName): GameEngineConfig {
subtitle: 'An open-world text adventure',
version: '1.0.0',
copyright: '',
language: 'en_US',
},
};
}
@@ -81,6 +83,7 @@ export function loadGameConfig(configPath: string, engine: EngineName): GameEngi
metadata: {
...fallback.metadata,
...(parsed.metadata ?? {}),
language: parsed.metadata?.language ?? parsed.locale ?? fallback.metadata.language,
},
};
}
+37 -2
View File
@@ -150,11 +150,13 @@ export class InkEngine {
const paragraphs: TurnResult['paragraphs'] = [];
const globalTags: StoryTag[] = [];
const turnTags: StoryTag[] = [];
while (this.story.canContinue) {
const rawText = this.story.Continue();
const text = String(rawText || '').trim();
const tags = parseTags(this.story.currentTags || []);
turnTags.push(...tags);
tags
.filter((tag) => tag.key === 'title' || tag.key === 'author')
@@ -170,7 +172,7 @@ export class InkEngine {
const choices = this.story.currentChoices.map((choice): ChoiceResult => {
const tags = parseTags(choice.tags || []);
const category = getTagValue(tags, 'action');
const letter = getTagValue(tags, 'letter');
const letter = getTagValue(tags, 'letter') || getTagValue(tags, 'key');
return {
index: choice.index,
text: String(choice.text || '').trim(),
@@ -179,13 +181,46 @@ export class InkEngine {
letter,
};
});
const inputMode = choices.length > 0 ? 'choice' : 'end';
const gameState: TurnResult['gameState'] = {};
if (inputMode === 'end') {
const errorTag = turnTags.find((tag) => tag.key === 'error');
const scoreTag = turnTags.find((tag) => tag.key === 'score');
if (!errorTag && !scoreTag) {
const message = 'Ink story ended without an explicit #score ending tag.';
const generatedErrorTag: StoryTag = { key: 'error', value: message };
globalTags.push(generatedErrorTag);
turnTags.push(generatedErrorTag);
}
const finalErrorTag = turnTags.find((tag) => tag.key === 'error');
const finalScoreTag = turnTags.find((tag) => tag.key === 'score');
if (finalErrorTag) {
gameState.endState = {
type: 'error',
message: finalErrorTag.value || finalErrorTag.param,
};
} else if (finalScoreTag) {
const numericScore = Number(finalScoreTag?.value);
if (Number.isFinite(numericScore)) {
gameState.score = numericScore;
}
gameState.endState = {
type: 'intended',
message: finalScoreTag.value || finalScoreTag.param,
};
}
}
return {
turnId: this.nextTurnId++,
paragraphs,
choices,
inputMode: choices.length > 0 ? 'choice' : 'end',
inputMode,
globalTags: globalTags.length > 0 ? globalTags : undefined,
gameState: Object.keys(gameState).length > 0 ? gameState : undefined,
};
}
}
+4
View File
@@ -35,6 +35,10 @@ export interface TurnResult {
score?: number;
moves?: number;
statusLine?: string;
endState?: {
type: 'intended' | 'error';
message?: string;
};
};
suggestions?: string[];
}
+8
View File
@@ -23,6 +23,14 @@ export function parseTag(raw: string): StoryTag | null {
return tag;
}
const colonMatch = text.match(/^([A-Za-z][\w-]*)\s*:\s*(.*?)\s*(?:\(([^)]*)\))?$/);
if (colonMatch) {
const tag: StoryTag = { key: normalizeKey(colonMatch[1]) };
tag.value = colonMatch[2].trim();
if (typeof colonMatch[3] !== 'undefined') tag.param = colonMatch[3].trim();
return tag;
}
const bareMatch = text.match(/^[A-Za-z][\w-]*$/);
if (bareMatch) {
return { key: normalizeKey(text) };