Compare commits

..
5 Commits
Author SHA1 Message Date
Joel Brock 185845f969 Fix growth speech bubble / voice mismatch
Pair each growth thought with its matching voice clip instead of always
playing 'I think I grew a little'. Lines without a matching clip now play no
audio, and 'Another day in the deep blue' plays the deepBlue clip. Bump SW to v15.
2026-06-20 11:54:48 -07:00
Joel Brock 3a4c2579a5 Reduce beaching event frequency
Lower the per-tick beaching chance from 18% to 4% and add a 5-minute cooldown
so beaching stays an occasional event instead of interrupting ocean play every
minute. Bump service worker cache to v14.
2026-06-20 11:50:33 -07:00
Joel Brock ac69e456d9 Use the custom turtle name consistently across all screens
Capture the turtle's name on the start screen so it is set before the beach,
beaching, and migration screens. Populate the beaching notice and migration
bonus text from turtleName (previously hardcoded 'Shelly'), pre-fill the
rename modal, and send migration-continue straight to the ocean since the
turtle is already named.
2026-06-20 11:44:47 -07:00
Joel Brock 5385e10a41 Wire remaining voice clips and add glub to ambient loop
Map the new turtvox clips to their matching speech bubbles (tickle replies,
species feeding, playmate greeting/farewell, salve, growth) and add
glub_glub_glub to the ambient underwater loop. Precache new media (v12 -> v13).
2026-06-20 11:39:48 -07:00
Joel Brock 762a698db4 Add turtle voice lines and ambient water SFX
Wire turtvox voice clips to their matching speech bubbles (predator alert,
shell bite, plastic ingestion, propeller strike, tickle reward, idle musing)
and loop water SFX as ambient underwater sound throughout the ocean scene.
Precache media in the service worker (v11 -> v12) for offline playback.
Also fix raw control bytes in the name-sanitization regex.
2026-06-20 11:05:51 -07:00
23 changed files with 156 additions and 30 deletions
+129 -26
View File
@@ -20,7 +20,7 @@ document.addEventListener('DOMContentLoaded', () => {
"Always maintain a minimum distance of 10 feet (3 meters) from sea turtles on land.",
"Crowding sea turtles causes them severe stress and can disrupt their natural nesting or resting behaviors.",
"Do not use flash photography near sea turtles; the bright flash disorients them and can disrupt nesting mothers.",
"Filing in beach holes and removing beach chairs prevents hatchlings from getting trapped on their way to the ocean."
"Filling in beach holes and removing beach chairs prevents hatchlings from getting trapped on their way to the ocean."
];
const PREDATOR_EMOJIS = ['🦀', '🦅', '🦝'];
@@ -61,6 +61,10 @@ document.addEventListener('DOMContentLoaded', () => {
let beachingProgress = 0; // 0 to 100
let beachingInterval = null;
let beachingTouristsInterval = null;
// Earliest time (ms) the next beaching event may trigger. Keeps beaching to
// an occasional event rather than something that interrupts every minute.
const BEACHING_COOLDOWN_MS = 5 * 60 * 1000;
let nextBeachingAllowedAt = 0;
// ==========================================
// 2. DOM ELEMENTS
@@ -119,6 +123,75 @@ document.addEventListener('DOMContentLoaded', () => {
sad: document.getElementById('turtle-mouth-sad')
};
// ==========================================
// AUDIO: Turtle voice lines + ambient water SFX
// ==========================================
const MEDIA_PATH = 'media/';
// Voice lines keyed to the speech bubble they accompany (file name → trigger).
const TURT_VOX = {
deepBlue: 'turtvox001_another_day_in_the_deep_blue.m4a',
bestHuman: 'turtvox002_best_human_ever.m4a',
bittenShell: 'turtvox003_bitten_shell_but_ok.m4a',
somebodysComing: 'turtvox004_somebodys_coming.m4a',
ateBagToxic: 'turtvox005_ate_a_bag_toxic.m4a',
propellerStrike: 'turtvox006_propeller_strike_fatal_wound.m4a',
heehee: 'turtvox006_heeheehee.m4a',
morePlease: 'turtvox008_more_please.m4a',
hehe: 'turtvox09_hehe_that_tickles.m4a',
yummySeagrass: 'turtvox011_yummy_seagrass.m4a',
letsPlay: 'turtvox012_another_turtle_lets_play.m4a',
swimSafe: 'turtvox013_swim_safe_friend.m4a',
applyingSalve: 'turtvox014_applying_salve.m4a',
grewALittle: 'turtvox15_I_think_I_grew_a_little.m4a',
crunchyCrab: 'turtvox016_crunchy_crab.m4a',
softJellyfish: 'turtvox017_soft_jellyfish.m4a'
};
const WATER_SFX = [
'water_sfx_001.m4a',
'water_sfx_002.m4a',
'water_sfx_003.m4a',
'turtvox008_glub_glub_glub.m4a'
];
// Play a single turtle voice line. Autoplay is allowed because the player has
// already tapped through the start screen before any voice line fires.
let currentVoice = null;
function playVoice(key) {
const file = TURT_VOX[key];
if (!file) return;
try {
if (currentVoice) currentVoice.pause();
currentVoice = new Audio(MEDIA_PATH + file);
currentVoice.volume = 0.9;
currentVoice.play().catch(() => {});
} catch (e) { /* audio unsupported */ }
}
// Ambient underwater sound — loops continuously through the water SFX clips
// for the whole time the player is in the ocean scene.
let ambientAudio = null;
let ambientIndex = 0;
function startAmbientWater() {
if (ambientAudio) return;
ambientIndex = 0;
ambientAudio = new Audio(MEDIA_PATH + WATER_SFX[0]);
ambientAudio.volume = 0.3;
ambientAudio.addEventListener('ended', () => {
if (!ambientAudio) return;
ambientIndex = (ambientIndex + 1) % WATER_SFX.length;
ambientAudio.src = MEDIA_PATH + WATER_SFX[ambientIndex];
ambientAudio.play().catch(() => {});
});
ambientAudio.play().catch(() => {});
}
function stopAmbientWater() {
if (!ambientAudio) return;
ambientAudio.pause();
ambientAudio = null;
}
// Buttons
const btnStartGame = document.getElementById('btn-start-game');
const btnShowFactsMain = document.getElementById('btn-show-facts-main');
@@ -136,6 +209,11 @@ document.addEventListener('DOMContentLoaded', () => {
const btnHeal = document.getElementById('btn-action-heal');
const feedDrawer = document.getElementById('feed-options-drawer');
// Name inputs / dynamic name labels
const inputTurtleNameStart = document.getElementById('input-turtle-name-start');
const beachingTurtleName = document.getElementById('beaching-turtle-name');
const bonusTurtleName = document.getElementById('bonus-turtle-name');
// HUD Outputs
const displayTurtleName = document.getElementById('display-turtle-name');
const displayTurtleSpecies = document.getElementById('display-turtle-species');
@@ -491,15 +569,19 @@ document.addEventListener('DOMContentLoaded', () => {
turtleAgeVal.textContent = formatAge(turtleAge);
applyGrowthStage();
if (wholeDays > 0 && Math.random() > 0.7) {
triggerThoughtBubble(getGrowthThought(), 2200);
const growth = getGrowthThought();
triggerThoughtBubble(growth.text, 2200, growth.voice);
}
}
saveProgress();
// Beaching happens on a regular cycle for healthy adults — roughly
// every few minutes of active play.
if (turtleAge > 7 && stats.health > 50 && Math.random() < 0.18) {
// Beaching is an occasional event for healthy adults. A cooldown plus a
// low per-tick chance keeps it to roughly once every several minutes
// rather than interrupting play every minute.
if (turtleAge > 7 && stats.health > 50 &&
Date.now() >= nextBeachingAllowedAt && Math.random() < 0.04) {
nextBeachingAllowedAt = Date.now() + BEACHING_COOLDOWN_MS;
changeState(STATES.BEACHING);
}
}, 10000);
@@ -533,10 +615,14 @@ document.addEventListener('DOMContentLoaded', () => {
if (currentState !== STATES.OCEAN) return;
spawnAmbientBubble();
}, 1200);
// 7. Ambient underwater sound for the ocean scene
startAmbientWater();
}
function clearOceanLoops() {
Object.values(oceanLoops).forEach(loop => clearInterval(loop));
stopAmbientWater();
}
// ==========================================
@@ -568,7 +654,7 @@ document.addEventListener('DOMContentLoaded', () => {
function sanitizeName(n) {
if (typeof n !== 'string') return 'Shelly';
// Strip control chars and HTML-relevant punctuation; cap at 12 like the input.
const cleaned = n.replace(/[-<>"'`\\]/g, '').trim().slice(0, 12);
const cleaned = n.replace(/[\x00-\x1f<>"'`\\]/g, '').trim().slice(0, 12);
return cleaned.length ? cleaned : 'Shelly';
}
@@ -686,11 +772,12 @@ document.addEventListener('DOMContentLoaded', () => {
}
function getGrowthThought() {
// Each line carries the voice clip that matches it (null = no audio).
const thoughts = [
"I think I grew a little! 🌱",
"Getting stronger! 💪",
"My shell feels bigger! 🐢",
"Another day in the deep blue 🌊"
{ text: "I think I grew a little! 🌱", voice: 'grewALittle' },
{ text: "Getting stronger! 💪", voice: null },
{ text: "My shell feels bigger! 🐢", voice: null },
{ text: "Another day in the deep blue 🌊", voice: 'deepBlue' }
];
return thoughts[Math.floor(Math.random() * thoughts.length)];
}
@@ -1175,6 +1262,7 @@ document.addEventListener('DOMContentLoaded', () => {
bonusInfoCard.classList.add('hidden');
}
bonusTurtleName.textContent = turtleName;
modals.migrationSuccess.classList.remove('hidden');
} else {
// Fail! Game over
@@ -1227,7 +1315,8 @@ document.addEventListener('DOMContentLoaded', () => {
}
// Dialog bubble
function triggerThoughtBubble(text, duration = 3000) {
function triggerThoughtBubble(text, duration = 3000, voice = null) {
if (voice) playVoice(voice);
thoughtText.textContent = text;
thoughtBubble.classList.remove('hidden');
@@ -1482,7 +1571,7 @@ document.addEventListener('DOMContentLoaded', () => {
updateStatBars();
setEyeState('happy');
setMouthState('normal');
triggerThoughtBubble(info.greeting, 2200);
triggerThoughtBubble(info.greeting, 2200, 'letsPlay');
// Random darting motion around the playpen
const pen = oceanPlaypen.getBoundingClientRect();
@@ -1550,7 +1639,7 @@ document.addEventListener('DOMContentLoaded', () => {
stats.joy = Math.min(100, stats.joy + Math.min(15, highFives * 2));
updateStatBars();
}
triggerThoughtBubble(info.farewell, 1800);
triggerThoughtBubble(info.farewell, 1800, 'swimSafe');
setTimeout(() => setEyeState('normal'), 1200);
const m = activePlaymate;
activePlaymate = null;
@@ -1568,7 +1657,7 @@ document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => setEyeState('normal'), 2000);
} else if (stats.health < 80) {
stats.health = Math.min(100, stats.health + 20);
triggerThoughtBubble("Applying salve... 🩹");
triggerThoughtBubble("Applying salve... 🩹", 3000, 'applyingSalve');
} else {
triggerThoughtBubble("I feel great! 🐢");
}
@@ -1632,17 +1721,17 @@ document.addEventListener('DOMContentLoaded', () => {
if (foodType === 'seagrass' && turtleSpecies === 'green') {
stats.hunger = Math.min(100, stats.hunger + 30);
stats.joy = Math.min(100, stats.joy + 10);
triggerThoughtBubble("Yummy seagrass! 🌿");
triggerThoughtBubble("Yummy seagrass! 🌿", 3000, 'yummySeagrass');
setEyeState('happy');
} else if (foodType === 'crabs' && turtleSpecies === 'loggerhead') {
stats.hunger = Math.min(100, stats.hunger + 30);
stats.joy = Math.min(100, stats.joy + 10);
triggerThoughtBubble("Crunchy crab! 🦀");
triggerThoughtBubble("Crunchy crab! 🦀", 3000, 'crunchyCrab');
setEyeState('happy');
} else if (foodType === 'jellyfish' && turtleSpecies === 'leatherback') {
stats.hunger = Math.min(100, stats.hunger + 30);
stats.joy = Math.min(100, stats.joy + 10);
triggerThoughtBubble("Soft jellyfish! 🪼");
triggerThoughtBubble("Soft jellyfish! 🪼", 3000, 'softJellyfish');
setEyeState('happy');
} else {
stats.hunger = Math.min(100, stats.hunger + 12);
@@ -1754,7 +1843,7 @@ document.addEventListener('DOMContentLoaded', () => {
stats.joy = Math.max(0, stats.joy - 20);
setEyeState('dizzy');
setMouthState('sad');
triggerThoughtBubble("Ate a bag... toxic! 🤢🛍️", 4000);
triggerThoughtBubble("Ate a bag... toxic! 🤢🛍️", 4000, 'ateBagToxic');
updateStatBars();
screens.ocean.classList.add('shake-element');
@@ -1971,7 +2060,7 @@ document.addEventListener('DOMContentLoaded', () => {
stats.health = Math.max(0, stats.health - 45);
setEyeState('dizzy');
setMouthState('sad');
triggerThoughtBubble("Propeller strike! Fatal wounds! 🚢💥", 4000);
triggerThoughtBubble("Propeller strike! Fatal wounds! 🚢💥", 4000, 'propellerStrike');
screens.ocean.classList.add('shake-element');
setTimeout(() => {
@@ -1994,7 +2083,7 @@ document.addEventListener('DOMContentLoaded', () => {
turtleContainer.classList.add('predator-alert');
setEyeState('normal');
setMouthState('sad');
triggerThoughtBubble("Something's coming! 😨", 1800);
triggerThoughtBubble("Something's coming! 😨", 1800, 'somebodysComing');
setTimeout(() => {
// Stay in alert until QTE resolves; cleanup happens in resolveSharkQte
}, 100);
@@ -2086,7 +2175,7 @@ document.addEventListener('DOMContentLoaded', () => {
stats.health = Math.max(0, stats.health - 8); // Minor shield bump damage
stats.joy = Math.max(0, stats.joy - 15);
setEyeState('normal');
triggerThoughtBubble("Bitten shell, but okay! 🛡️🦈");
triggerThoughtBubble("Bitten shell, but okay! 🛡️🦈", 3000, 'bittenShell');
} else {
// Exposed shark bite
stats.health = Math.max(0, stats.health - 50);
@@ -2111,6 +2200,7 @@ document.addEventListener('DOMContentLoaded', () => {
// 9. BEACHING / TOURIST SHIELD EVENT
// ==========================================
function initBeachingPhase() {
beachingTurtleName.textContent = turtleName;
beachingProgress = 0;
beachingProgressFill.style.width = '0%';
beachingProgressText.textContent = '0%';
@@ -2310,6 +2400,7 @@ document.addEventListener('DOMContentLoaded', () => {
// Start Conservation Game
btnStartGame.addEventListener('click', () => {
turtleName = sanitizeName(inputTurtleNameStart.value);
changeState(STATES.BEACH);
});
@@ -2323,14 +2414,16 @@ document.addEventListener('DOMContentLoaded', () => {
document.getElementById('beach-tutorial-overlay').classList.add('hidden');
});
// Migration Success Continue Action
// Migration Success Continue Action — the turtle was already named at the
// start, so head straight into the ocean.
btnMigrationContinue.addEventListener('click', () => {
modals.migrationSuccess.classList.add('hidden');
modals.rename.classList.remove('hidden');
changeState(STATES.OCEAN);
});
// Rename Turtle Action
btnRenameTurtle.addEventListener('click', () => {
document.getElementById('input-turtle-name').value = turtleName;
modals.rename.classList.remove('hidden');
});
@@ -2394,8 +2487,14 @@ document.addEventListener('DOMContentLoaded', () => {
turtleContainer.classList.remove('being-tickled');
setEyeState('normal');
if (rubCount >= 3) {
const thoughts = ["Hehe that tickles! ✨", "More please! 💕", "Best human ever! 🥰", "Hehehehe! 😆"];
triggerThoughtBubble(thoughts[Math.floor(Math.random() * thoughts.length)], 1800);
const tickleThoughts = [
{ text: "Hehe that tickles! ✨", voice: 'hehe' },
{ text: "More please! 💕", voice: 'morePlease' },
{ text: "Best human ever! 🥰", voice: 'bestHuman' },
{ text: "Hehehehe! 😆", voice: 'heehee' }
];
const pick = tickleThoughts[Math.floor(Math.random() * tickleThoughts.length)];
triggerThoughtBubble(pick.text, 1800, pick.voice);
stats.joy = Math.min(100, stats.joy + 5);
updateStatBars();
}
@@ -2422,7 +2521,7 @@ document.addEventListener('DOMContentLoaded', () => {
// ==========================================
// IDLE BEHAVIORS — turtle does adorable things on its own
// ==========================================
const IDLE_BEHAVIORS = ['blink', 'bubbles', 'flap', 'lookAround', 'roll', 'yawn'];
const IDLE_BEHAVIORS = ['blink', 'bubbles', 'flap', 'lookAround', 'roll', 'yawn', 'muse'];
function triggerIdleBehavior() {
// Don't interrupt if turtle is being interacted with or unhappy/sick
@@ -2475,6 +2574,10 @@ document.addEventListener('DOMContentLoaded', () => {
}, 800);
cleanup(900);
break;
case 'muse':
triggerThoughtBubble("Another day in the deep blue... 🌊", 2600, 'deepBlue');
cleanup(2700);
break;
}
}
+5 -2
View File
@@ -108,6 +108,9 @@
</label>
</div>
<label for="input-turtle-name-start" class="intro-action"><strong>Name your turtle:</strong></label>
<input type="text" id="input-turtle-name-start" maxlength="12" placeholder="Shelly" class="text-input">
<button id="btn-start-game" class="btn-primary btn-pulse">Start Conservation Game</button>
</div>
@@ -530,7 +533,7 @@
<!-- HUD Alerts -->
<div class="beaching-instructions-card">
<p>💤 Shelly has beached to rest and digest. <strong>Ignorant tourists are trying to crowd closer for selfies!</strong></p>
<p>💤 <span id="beaching-turtle-name">Shelly</span> has beached to rest and digest. <strong>Ignorant tourists are trying to crowd closer for selfies!</strong></p>
<p class="text-accent"><strong>Tap tourists to push them back outside the 10-foot boundary!</strong></p>
</div>
</div>
@@ -602,7 +605,7 @@
<div id="bonus-info-card" class="bonus-box hidden">
<h4>🎉 Conservation Goodies Awarded!</h4>
<p>Superb beach management! Since you beat the unmanaged average of 25% (> 5 hatchlings), Shelly starts the ocean journey with: <strong>+20 Joy</strong>, <strong>+20 Fullness</strong>, and a **+20 Health** head-start!</p>
<p>Superb beach management! Since you beat the unmanaged average of 25% (> 5 hatchlings), <span id="bonus-turtle-name">Shelly</span> starts the ocean journey with: <strong>+20 Joy</strong>, <strong>+20 Fullness</strong>, and a **+20 Health** head-start!</p>
</div>
<button id="btn-migration-continue" class="btn-primary">Continue to Ocean Journey</button>
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22 -2
View File
@@ -1,4 +1,4 @@
const CACHE_NAME = 'tortugotchi-v11';
const CACHE_NAME = 'tortugotchi-v15';
const ASSETS = [
'./',
'./index.html',
@@ -6,7 +6,27 @@ const ASSETS = [
'./app.js',
'./manifest.json',
'./icon-192.png',
'./icon-512.png'
'./icon-512.png',
'./media/turtvox001_another_day_in_the_deep_blue.m4a',
'./media/turtvox002_best_human_ever.m4a',
'./media/turtvox003_bitten_shell_but_ok.m4a',
'./media/turtvox004_somebodys_coming.m4a',
'./media/turtvox005_ate_a_bag_toxic.m4a',
'./media/turtvox006_propeller_strike_fatal_wound.m4a',
'./media/turtvox006_heeheehee.m4a',
'./media/turtvox008_more_please.m4a',
'./media/turtvox008_glub_glub_glub.m4a',
'./media/turtvox09_hehe_that_tickles.m4a',
'./media/turtvox011_yummy_seagrass.m4a',
'./media/turtvox012_another_turtle_lets_play.m4a',
'./media/turtvox013_swim_safe_friend.m4a',
'./media/turtvox014_applying_salve.m4a',
'./media/turtvox15_I_think_I_grew_a_little.m4a',
'./media/turtvox016_crunchy_crab.m4a',
'./media/turtvox017_soft_jellyfish.m4a',
'./media/water_sfx_001.m4a',
'./media/water_sfx_002.m4a',
'./media/water_sfx_003.m4a'
];
self.addEventListener('install', (e) => {