Document markup and improve choice tags
This commit is contained in:
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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' };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 ? '🔊' : '🔇';
|
||||
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}%`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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 }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user