Tourismustal: Audio-Modus fuer Meldungen + eigene Mega-Motive
- Umschalter Topbar (Lese-/Audio-Modus, persistiert): Im Audio-Modus laeuft das Spiel weiter, Meldungen werden per Browser-TTS (deutsche Stimme) vorgelesen und erscheinen als antippbare Info-Zeile oben; Klick oder 🔔-Log oeffnet die volle Karte. Entscheidungen (A/B) und wichtige Karten (Sieg, Zwischenbilanz, Bank) bleiben immer modal. - Megabauwerke: 9 eigene Sprites (mehr Elemente statt vergroesserter Fenster/Tueren - glaubwuerdige Proportionen), zentriert, ~80 % der bisherigen Darstellungsgroesse; Fallback normales Motiv x1,4. Rodelbahn aus der Merge-Liste (dynamische Vektor-Bahn). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 70 KiB |
@@ -447,3 +447,15 @@ button.secondary {
|
|||||||
#buildmenu { width: 220px; }
|
#buildmenu { width: 220px; }
|
||||||
#graphs { width: 420px; height: 120px; }
|
#graphs { width: 420px; height: 120px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Info-Zeile oben (Audio-Modus): Meldung ohne Spielstopp, antippbar */
|
||||||
|
#infoBanner {
|
||||||
|
position: fixed; top: 58px; left: 50%; transform: translateX(-50%);
|
||||||
|
max-width: min(680px, 92vw); padding: 9px 16px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; gap: 10px; font-size: 14px;
|
||||||
|
z-index: 60; animation: ibSlide .25s ease-out;
|
||||||
|
}
|
||||||
|
#infoBanner.hidden { display: none; }
|
||||||
|
#infoBanner .ib-icon { font-size: 20px; }
|
||||||
|
#infoBanner .ib-hint { color: var(--muted); font-size: 12px; margin-left: 4px; white-space: nowrap; }
|
||||||
|
@keyframes ibSlide { from { transform: translate(-50%, -14px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } }
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<button data-speed="2" class="icon-btn speed-btn" title="Schnell">2×</button>
|
<button data-speed="2" class="icon-btn speed-btn" title="Schnell">2×</button>
|
||||||
<button data-speed="4" class="icon-btn speed-btn" title="Sehr schnell">4×</button>
|
<button data-speed="4" class="icon-btn speed-btn" title="Sehr schnell">4×</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="btnInfoMode" class="icon-btn" title="Meldungs-Modus umschalten">📖</button>
|
||||||
<button id="btnLog" class="icon-btn" title="Meldungen">🔔<span id="logBadge" class="badge hidden">0</span></button>
|
<button id="btnLog" class="icon-btn" title="Meldungen">🔔<span id="logBadge" class="badge hidden">0</span></button>
|
||||||
<button id="btnSound" class="icon-btn" title="Sound an/aus">🔊</button>
|
<button id="btnSound" class="icon-btn" title="Sound an/aus">🔊</button>
|
||||||
<button id="btnMusic" class="icon-btn" title="Musik an/aus">🎵</button>
|
<button id="btnMusic" class="icon-btn" title="Musik an/aus">🎵</button>
|
||||||
@@ -101,6 +102,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast" class="panel hidden"></div>
|
<div id="toast" class="panel hidden"></div>
|
||||||
|
<div id="infoBanner" class="panel hidden"></div>
|
||||||
|
|
||||||
<div id="cardOverlay" class="overlay hidden">
|
<div id="cardOverlay" class="overlay hidden">
|
||||||
<div class="card panel">
|
<div class="card panel">
|
||||||
|
|||||||
@@ -36,6 +36,16 @@ const SPRITE_DEFS = {
|
|||||||
rescue: { w: 60, bottom: 9 },
|
rescue: { w: 60, bottom: 9 },
|
||||||
snow: { w: 44, bottom: 7 },
|
snow: { w: 44, bottom: 7 },
|
||||||
bikepark: { w: 108, bottom: 14 }, // 2×2-Areal, zentriert gezeichnet
|
bikepark: { w: 108, bottom: 14 }, // 2×2-Areal, zentriert gezeichnet
|
||||||
|
// Megabauwerke: eigene Motive (mehr Elemente statt vergrößerter Fenster)
|
||||||
|
mega_restaurant: { w: 104, bottom: 12 },
|
||||||
|
mega_hotel: { w: 106, bottom: 12 },
|
||||||
|
mega_staffhouse: { w: 102, bottom: 12 },
|
||||||
|
mega_snow: { w: 96, bottom: 11 },
|
||||||
|
mega_badesee: { w: 110, bottom: 14 },
|
||||||
|
mega_ropes: { w: 106, bottom: 11 },
|
||||||
|
mega_playground: { w: 104, bottom: 12 },
|
||||||
|
mega_bikerental: { w: 98, bottom: 12 },
|
||||||
|
mega_trail: { w: 100, bottom: 11 },
|
||||||
ropes: { w: 58, bottom: 8 },
|
ropes: { w: 58, bottom: 8 },
|
||||||
bikerental: { w: 50, bottom: 8 },
|
bikerental: { w: 50, bottom: 8 },
|
||||||
platform: { w: 62, bottom: 9 },
|
platform: { w: 62, bottom: 9 },
|
||||||
@@ -1679,11 +1689,15 @@ export class IsoRenderer {
|
|||||||
x = ctr.x; y = ctr.y;
|
x = ctr.x; y = ctr.y;
|
||||||
}
|
}
|
||||||
// KI-Sprites für Gebäude (Bahnen & Rodelbahn bleiben Vektor: dynamisch)
|
// KI-Sprites für Gebäude (Bahnen & Rodelbahn bleiben Vektor: dynamisch)
|
||||||
const spriteName = type === 'hotel' && level >= 2 ? 'hotel2' : type;
|
let spriteName = type === 'hotel' && level >= 2 ? 'hotel2' : type;
|
||||||
let spriteScale = type === 'hotel'
|
let spriteScale = type === 'hotel'
|
||||||
? (level >= 3 ? 1.15 : 1)
|
? (level >= 3 ? 1.15 : 1)
|
||||||
: [1, 1.15, 1.3][Math.min(2, level - 1)];
|
: [1, 1.15, 1.3][Math.min(2, level - 1)];
|
||||||
if (tile.mega) spriteScale *= 1.75; // Megabauwerk füllt sein 2×2-Areal
|
if (tile.mega) {
|
||||||
|
// Eigenes Mega-Motiv (glaubwürdige Proportionen); Fallback: normal ×1,4
|
||||||
|
if (this.sprites[`mega_${type}`]) { spriteName = `mega_${type}`; spriteScale = 1; }
|
||||||
|
else spriteScale *= 1.4;
|
||||||
|
}
|
||||||
if (type !== 'lift' && type !== 'gondola' && type !== 'sled'
|
if (type !== 'lift' && type !== 'gondola' && type !== 'sled'
|
||||||
&& this._sprite(spriteName, x, y, spriteScale)) {
|
&& this._sprite(spriteName, x, y, spriteScale)) {
|
||||||
this._badge(x + 18, y - 34, level);
|
this._badge(x + 18, y - 34, level);
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ function checkEvents() {
|
|||||||
if (state.goalsDone && !state.firedEvents.has('win')) {
|
if (state.goalsDone && !state.firedEvents.has('win')) {
|
||||||
state.firedEvents.add('win');
|
state.firedEvents.add('win');
|
||||||
ui.showCard({
|
ui.showCard({
|
||||||
id: 'win', icon: '🏆', title: 'Alle Ziele erreicht!', sound: 'win',
|
id: 'win', icon: '🏆', title: 'Alle Ziele erreicht!', sound: 'win', important: true,
|
||||||
text: `Großartig! Nach ${state.day} Tagen hast du alle Ziele dieser Szene `
|
text: `Großartig! Nach ${state.day} Tagen hast du alle Ziele dieser Szene `
|
||||||
+ `geschafft – mit ${state.stats.maxGuests} Gästen am besten Tag, `
|
+ `geschafft – mit ${state.stats.maxGuests} Gästen am besten Tag, `
|
||||||
+ `${state.money.toLocaleString('de-AT')} € am Konto und einem Naturwert von `
|
+ `${state.money.toLocaleString('de-AT')} € am Konto und einem Naturwert von `
|
||||||
@@ -269,7 +269,7 @@ function checkEvents() {
|
|||||||
state.firedEvents.add('deadline');
|
state.firedEvents.add('deadline');
|
||||||
const done = state.scenario.goals.filter(g => g.check(state)).length;
|
const done = state.scenario.goals.filter(g => g.check(state)).length;
|
||||||
ui.showCard({
|
ui.showCard({
|
||||||
id: 'deadline', icon: '⏰', title: 'Zwischenbilanz',
|
id: 'deadline', icon: '⏰', title: 'Zwischenbilanz', important: true,
|
||||||
text: `Die Zeit dieser Szene ist um. Du hast ${done} von `
|
text: `Die Zeit dieser Szene ist um. Du hast ${done} von `
|
||||||
+ `${state.scenario.goals.length} Zielen erreicht.\n\n`
|
+ `${state.scenario.goals.length} Zielen erreicht.\n\n`
|
||||||
+ `Kein Problem – du kannst weiterspielen und die restlichen Ziele noch schaffen!`,
|
+ `Kein Problem – du kannst weiterspielen und die restlichen Ziele noch schaffen!`,
|
||||||
@@ -360,7 +360,7 @@ function advanceDay() {
|
|||||||
const fs = state.forcedSale;
|
const fs = state.forcedSale;
|
||||||
state.forcedSale = null;
|
state.forcedSale = null;
|
||||||
ui.showCard({
|
ui.showCard({
|
||||||
id: `forced-${state.day}`, icon: '🏦', title: 'Die Bank greift ein!',
|
id: `forced-${state.day}`, icon: '🏦', title: 'Die Bank greift ein!', important: true,
|
||||||
text: `Deine Schulden sind über 15.000 € gestiegen. Die Bank hat `
|
text: `Deine Schulden sind über 15.000 € gestiegen. Die Bank hat `
|
||||||
+ `„${fs.name}" zwangsverkauft – nur ${fs.refund.toLocaleString('de-AT')} € Erlös.\n\n`
|
+ `„${fs.name}" zwangsverkauft – nur ${fs.refund.toLocaleString('de-AT')} € Erlös.\n\n`
|
||||||
+ 'Wer baut, muss die LAUFENDEN Kosten mitplanen: Instandhaltung und '
|
+ 'Wer baut, muss die LAUFENDEN Kosten mitplanen: Instandhaltung und '
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ function effVal(b, key) {
|
|||||||
// Verschmelzen: liegen 4 gleiche 1×1-Gebäude im 2×2-Quadrat, werden sie zu
|
// Verschmelzen: liegen 4 gleiche 1×1-Gebäude im 2×2-Quadrat, werden sie zu
|
||||||
// EINEM Megabauwerk (size 2, mega). Bahnen, Bus & Bikepark ausgenommen.
|
// EINEM Megabauwerk (size 2, mega). Bahnen, Bus & Bikepark ausgenommen.
|
||||||
const MERGEABLE = ['restaurant', 'hotel', 'staffhouse', 'snow',
|
const MERGEABLE = ['restaurant', 'hotel', 'staffhouse', 'snow',
|
||||||
'badesee', 'sled', 'ropes', 'playground', 'bikerental', 'trail'];
|
'badesee', 'ropes', 'playground', 'bikerental', 'trail'];
|
||||||
function tryMerge(state, c, r) {
|
function tryMerge(state, c, r) {
|
||||||
const type = state.map[r]?.[c]?.building;
|
const type = state.map[r]?.[c]?.building;
|
||||||
if (!type || !MERGEABLE.includes(type)) return null;
|
if (!type || !MERGEABLE.includes(type)) return null;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export class UI {
|
|||||||
this.cardOpen = false;
|
this.cardOpen = false;
|
||||||
this.speedBeforeCard = 1;
|
this.speedBeforeCard = 1;
|
||||||
this.collectedCards = [];
|
this.collectedCards = [];
|
||||||
|
this.infoMode = localStorage.getItem('tt_infomode') || 'read';
|
||||||
this.activeBuild = null;
|
this.activeBuild = null;
|
||||||
this.toastTimer = null;
|
this.toastTimer = null;
|
||||||
|
|
||||||
@@ -140,6 +141,18 @@ export class UI {
|
|||||||
$('playerPanel').classList.toggle('hidden');
|
$('playerPanel').classList.toggle('hidden');
|
||||||
telemetry.log('player_panel', { open: !$('playerPanel').classList.contains('hidden') });
|
telemetry.log('player_panel', { open: !$('playerPanel').classList.contains('hidden') });
|
||||||
});
|
});
|
||||||
|
$('btnInfoMode').addEventListener('click', e => {
|
||||||
|
audio.click();
|
||||||
|
this.infoMode = this.infoMode === 'audio' ? 'read' : 'audio';
|
||||||
|
localStorage.setItem('tt_infomode', this.infoMode);
|
||||||
|
this._syncInfoModeBtn(e.currentTarget);
|
||||||
|
this._speak(null);
|
||||||
|
this.toast(this.infoMode === 'audio'
|
||||||
|
? '🔊 Audio-Modus: Meldungen werden vorgelesen, das Spiel läuft weiter. Entscheidungen pausieren weiterhin.'
|
||||||
|
: '📖 Lese-Modus: Meldungen öffnen sich als Karte, das Spiel pausiert.', 5000);
|
||||||
|
telemetry.log('infomode', { mode: this.infoMode });
|
||||||
|
});
|
||||||
|
this._syncInfoModeBtn($('btnInfoMode'));
|
||||||
$('btnHelp').addEventListener('click', () => { audio.click(); this.showOnboarding(); });
|
$('btnHelp').addEventListener('click', () => { audio.click(); this.showOnboarding(); });
|
||||||
$('btnLog').addEventListener('click', () => { audio.click(); this._openLog(); });
|
$('btnLog').addEventListener('click', () => { audio.click(); this._openLog(); });
|
||||||
$('btnReport').addEventListener('click', () => {
|
$('btnReport').addEventListener('click', () => {
|
||||||
@@ -182,6 +195,14 @@ export class UI {
|
|||||||
player.onChange?.();
|
player.onChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_syncInfoModeBtn(btn) {
|
||||||
|
if (!btn) return;
|
||||||
|
btn.textContent = this.infoMode === 'audio' ? '🔊' : '📖';
|
||||||
|
btn.title = this.infoMode === 'audio'
|
||||||
|
? 'Audio-Modus: Meldungen werden vorgelesen (umschalten auf Lese-Modus)'
|
||||||
|
: 'Lese-Modus: Meldungen pausieren das Spiel (umschalten auf Audio-Modus)';
|
||||||
|
}
|
||||||
|
|
||||||
markSpeed(sp) {
|
markSpeed(sp) {
|
||||||
document.querySelectorAll('.speed-btn').forEach(b =>
|
document.querySelectorAll('.speed-btn').forEach(b =>
|
||||||
b.classList.toggle('active', Number(b.dataset.speed) === sp));
|
b.classList.toggle('active', Number(b.dataset.speed) === sp));
|
||||||
@@ -436,11 +457,60 @@ export class UI {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Event-Karten ----------
|
// ---------- Event-Karten ----------
|
||||||
|
// Zwei Info-Modi (Umschalter in der Topbar):
|
||||||
|
// 'read' – wie gehabt: Karte öffnet sich, Spiel pausiert.
|
||||||
|
// 'audio' – Spielfluss läuft weiter: Meldung wird vorgelesen und
|
||||||
|
// erscheint als Info-Zeile oben; Klick darauf (oder 🔔-Log)
|
||||||
|
// öffnet die volle Karte. Entscheidungen (A/B) und wichtige
|
||||||
|
// Meldungen (Sieg, Bank …) bleiben IMMER modal.
|
||||||
showCard(card) {
|
showCard(card) {
|
||||||
|
if (this.infoMode === 'audio' && !card.choices && !card.important && !card.forceModal) {
|
||||||
|
this._banner(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.cardQueue.push(card);
|
this.cardQueue.push(card);
|
||||||
if (!this.cardOpen) this._nextCard();
|
if (!this.cardOpen) this._nextCard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_banner(card) {
|
||||||
|
if (!card.transient) {
|
||||||
|
this.collectedCards.push(card);
|
||||||
|
const badge = $('logBadge');
|
||||||
|
badge.textContent = this.collectedCards.length;
|
||||||
|
badge.classList.remove('hidden');
|
||||||
|
telemetry.log('event_card', { id: card.id || card.title, banner: true });
|
||||||
|
}
|
||||||
|
const el = $('infoBanner');
|
||||||
|
el.innerHTML = `<span class="ib-icon">${card.icon}</span><b>${card.title}</b>`
|
||||||
|
+ '<span class="ib-hint">antippen für Details</span>';
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
el.onclick = () => {
|
||||||
|
el.classList.add('hidden');
|
||||||
|
this._speak(null); // Vorlesen stoppen
|
||||||
|
this.showCard({ ...card, transient: true, forceModal: true });
|
||||||
|
};
|
||||||
|
clearTimeout(this._bannerTimer);
|
||||||
|
this._bannerTimer = setTimeout(() => el.classList.add('hidden'), 9000);
|
||||||
|
this._speak(`${card.title}. ${card.text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sprachausgabe (Browser-TTS, deutsche Stimme). null = stoppen.
|
||||||
|
_speak(text) {
|
||||||
|
if (!('speechSynthesis' in window)) return;
|
||||||
|
speechSynthesis.cancel();
|
||||||
|
if (!text) return;
|
||||||
|
const clean = text
|
||||||
|
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}]/gu, '') // Emojis raus
|
||||||
|
.replace(/[„"«»]/g, '').replace(/\s+/g, ' ').trim();
|
||||||
|
const u = new SpeechSynthesisUtterance(clean);
|
||||||
|
u.lang = 'de-DE';
|
||||||
|
u.rate = 1.02;
|
||||||
|
const voices = speechSynthesis.getVoices().filter(v => v.lang?.startsWith('de'));
|
||||||
|
const nice = voices.find(v => /anna|petra|vicki|katja|helena/i.test(v.name)) || voices[0];
|
||||||
|
if (nice) u.voice = nice;
|
||||||
|
speechSynthesis.speak(u);
|
||||||
|
}
|
||||||
|
|
||||||
_nextCard() {
|
_nextCard() {
|
||||||
const card = this.cardQueue.shift();
|
const card = this.cardQueue.shift();
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
@@ -605,7 +675,7 @@ export class UI {
|
|||||||
li.innerHTML = `${c.icon} <b>${c.title}</b><small>${c.text.split('\n')[0]}</small>`;
|
li.innerHTML = `${c.icon} <b>${c.title}</b><small>${c.text.split('\n')[0]}</small>`;
|
||||||
li.addEventListener('click', () => {
|
li.addEventListener('click', () => {
|
||||||
$('logOverlay').classList.add('hidden');
|
$('logOverlay').classList.add('hidden');
|
||||||
this.showCard({ ...c, transient: true });
|
this.showCard({ ...c, transient: true, forceModal: true });
|
||||||
});
|
});
|
||||||
list.appendChild(li);
|
list.appendChild(li);
|
||||||
}
|
}
|
||||||
|
|||||||