- _headers: add CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, HSTS, COOP, CORP, COEP - sw.js: gate fetch handler to GET + http(s) + same-origin; return 504 on offline non-document failures; bump cache to v11 - app.js: validate every field of the localStorage save (allowlist species, clamp stats, coerce age, reject oversized payloads, strip HTML-relevant chars from name); apply same sanitizer to rename input
2520 lines
85 KiB
JavaScript
2520 lines
85 KiB
JavaScript
/**
|
|
* Tortugotchi: Sea Turtle PWA Game Engine
|
|
* Core Game Logic, State Machine, and Interactions
|
|
*/
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
// ==========================================
|
|
// 1. STATE & CONSTANTS
|
|
// ==========================================
|
|
const STATES = {
|
|
START: 'screen-start',
|
|
BEACH: 'screen-beach',
|
|
OCEAN: 'screen-ocean',
|
|
BEACHING: 'screen-beaching'
|
|
};
|
|
|
|
const CONSERVATION_FACTS = [
|
|
"Federal law prohibits touching, disturbing, or harassing sea turtles under the Endangered Species Act (ESA).",
|
|
"Fines for disturbing sea turtles or nesting sites can reach up to $25,000, along with potential jail time.",
|
|
"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."
|
|
];
|
|
|
|
const PREDATOR_EMOJIS = ['🦀', '🦅', '🦝'];
|
|
const TRASH_EMOJIS = ['🥤', '🍼', '🍿', '🛍️'];
|
|
|
|
// Game state
|
|
let currentState = STATES.START;
|
|
let turtleName = 'Shelly';
|
|
let turtleSpecies = 'green'; // 'green' | 'loggerhead' | 'leatherback'
|
|
let turtleAge = 0; // days
|
|
let hatchlingsSaved = 0;
|
|
let beachTimer = null;
|
|
let beachTimeRemaining = 45; // seconds
|
|
let beachHazardsInterval = null;
|
|
let beachHatchInterval = null;
|
|
|
|
// Ocean Care stats
|
|
let stats = {
|
|
health: 100,
|
|
hunger: 80, // 100 = full, 0 = starving
|
|
joy: 80,
|
|
clean: 100
|
|
};
|
|
let oceanLoops = {
|
|
decay: null,
|
|
hazards: null,
|
|
debris: null,
|
|
age: null,
|
|
idle: null,
|
|
bubbles: null
|
|
};
|
|
let activeQte = null; // 'net' | 'boat' | 'shark'
|
|
let isHidingInShell = false;
|
|
let isSick = false;
|
|
let startWithBonus = false;
|
|
|
|
// Beaching Event state
|
|
let beachingProgress = 0; // 0 to 100
|
|
let beachingInterval = null;
|
|
let beachingTouristsInterval = null;
|
|
|
|
// ==========================================
|
|
// 2. DOM ELEMENTS
|
|
// ==========================================
|
|
const screens = {
|
|
start: document.getElementById('screen-start'),
|
|
beach: document.getElementById('screen-beach'),
|
|
ocean: document.getElementById('screen-ocean'),
|
|
beaching: document.getElementById('screen-beaching')
|
|
};
|
|
|
|
// Modals
|
|
const modals = {
|
|
facts: document.getElementById('modal-facts'),
|
|
rename: document.getElementById('modal-rename'),
|
|
gameover: document.getElementById('modal-gameover'),
|
|
migrationSuccess: document.getElementById('modal-migration-success')
|
|
};
|
|
|
|
// Migration Success Elements
|
|
const btnMigrationContinue = document.getElementById('btn-migration-continue');
|
|
const successSavedCount = document.getElementById('success-saved-count');
|
|
const successSavedPct = document.getElementById('success-saved-pct');
|
|
const bonusInfoCard = document.getElementById('bonus-info-card');
|
|
|
|
// Interactive Sandboxes
|
|
const beachSandbox = document.getElementById('beach-sandbox');
|
|
const beachHazardsLayer = document.getElementById('beach-hazards-layer');
|
|
const hatchlingCrawlersLayer = document.getElementById('hatchling-crawlers-layer');
|
|
const beachTrashCan = document.getElementById('beach-trash-can');
|
|
const oceanPlaypen = document.getElementById('ocean-playpen');
|
|
const floatingItemsContainer = document.getElementById('floating-items-container');
|
|
const cleaningGameOverlay = document.getElementById('cleaning-game-overlay');
|
|
const cleaningBrush = document.getElementById('cleaning-brush');
|
|
const beachingSandbox = document.getElementById('beaching-sandbox');
|
|
const touristSpawnLayer = document.getElementById('tourist-spawn-layer');
|
|
|
|
// Turtle SVG components
|
|
const turtleContainer = document.getElementById('sea-turtle-container');
|
|
const algaeSpots = document.getElementById('turtle-algae-spots');
|
|
const thoughtBubble = document.getElementById('turtle-thought-bubble');
|
|
const thoughtText = document.getElementById('thought-text');
|
|
|
|
// Eye States
|
|
const eyes = {
|
|
normal: document.getElementById('turtle-eyes-normal'),
|
|
happy: document.getElementById('turtle-eyes-happy'),
|
|
dizzy: document.getElementById('turtle-eyes-dizzy'),
|
|
sleeping: document.getElementById('turtle-eyes-sleeping')
|
|
};
|
|
|
|
// Mouth States
|
|
const mouths = {
|
|
normal: document.getElementById('turtle-mouth-normal'),
|
|
eating: document.getElementById('turtle-mouth-eating'),
|
|
sad: document.getElementById('turtle-mouth-sad')
|
|
};
|
|
|
|
// Buttons
|
|
const btnStartGame = document.getElementById('btn-start-game');
|
|
const btnShowFactsMain = document.getElementById('btn-show-facts-main');
|
|
const btnCloseFacts = document.getElementById('btn-close-facts');
|
|
const btnCloseFactsBottom = document.getElementById('btn-close-facts-bottom');
|
|
const btnDismissBeachTut = document.getElementById('btn-dismiss-beach-tut');
|
|
const btnRenameTurtle = document.getElementById('btn-rename-turtle');
|
|
const btnSaveRename = document.getElementById('btn-save-name');
|
|
const btnRestartGame = document.getElementById('btn-restart-game');
|
|
|
|
// Ocean Actions
|
|
const btnFeed = document.getElementById('btn-action-feed');
|
|
const btnClean = document.getElementById('btn-action-clean');
|
|
const btnPlay = document.getElementById('btn-action-play');
|
|
const btnHeal = document.getElementById('btn-action-heal');
|
|
const feedDrawer = document.getElementById('feed-options-drawer');
|
|
|
|
// HUD Outputs
|
|
const displayTurtleName = document.getElementById('display-turtle-name');
|
|
const displayTurtleSpecies = document.getElementById('display-turtle-species');
|
|
const turtleAgeVal = document.getElementById('turtle-age-val');
|
|
const txtHatchlingsSaved = document.getElementById('hatchlings-saved-count');
|
|
const beachProgressFill = document.getElementById('beach-progress-fill');
|
|
const beachFeedbackText = document.getElementById('beach-feedback');
|
|
const lightPollutionHud = document.getElementById('light-pollution-hud');
|
|
|
|
// Stat Bars
|
|
const bars = {
|
|
health: document.getElementById('bar-health'),
|
|
hunger: document.getElementById('bar-hunger'),
|
|
joy: document.getElementById('bar-joy'),
|
|
clean: document.getElementById('bar-clean')
|
|
};
|
|
const texts = {
|
|
health: document.getElementById('stat-health-text'),
|
|
hunger: document.getElementById('stat-hunger-text'),
|
|
joy: document.getElementById('stat-joy-text'),
|
|
clean: document.getElementById('stat-clean-text')
|
|
};
|
|
|
|
// QTE HUD elements
|
|
const qteNet = document.getElementById('qte-net-overlay');
|
|
const netSwipeFill = document.getElementById('net-swipe-fill');
|
|
const qteBoat = document.getElementById('qte-boat-overlay');
|
|
const btnQteDive = document.getElementById('btn-qte-dive');
|
|
const qteShark = document.getElementById('qte-shark-overlay');
|
|
const btnQteHide = document.getElementById('btn-qte-hide');
|
|
|
|
// Beaching Event HUD elements
|
|
const beachingProgressFill = document.getElementById('beaching-progress-fill');
|
|
const beachingProgressText = document.getElementById('beaching-progress-text');
|
|
const touristFactText = document.getElementById('tourist-fact-text');
|
|
|
|
// PWA Banner Elements
|
|
const pwaInstallBanner = document.getElementById('pwa-install-banner');
|
|
const btnPwaInstall = document.getElementById('btn-pwa-install');
|
|
const btnPwaClose = document.getElementById('btn-pwa-close');
|
|
|
|
// ==========================================
|
|
// 3. PWA INSTALLATION PROMPT
|
|
// ==========================================
|
|
// iOS Safari does NOT fire `beforeinstallprompt` and has no programmatic
|
|
// install API. Instead we detect iOS and show a manual instruction banner
|
|
// pointing the user to Share → Add to Home Screen.
|
|
const ua = navigator.userAgent || '';
|
|
const isIos =
|
|
/iPad|iPhone|iPod/.test(ua) ||
|
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); // iPad iPadOS
|
|
const isInStandalone =
|
|
window.matchMedia('(display-mode: standalone)').matches ||
|
|
window.navigator.standalone === true;
|
|
const dismissedKey = 'tortugotchi_pwa_dismissed';
|
|
const wasDismissed = localStorage.getItem(dismissedKey) === '1';
|
|
|
|
const bannerTextEl = pwaInstallBanner.querySelector('.banner-text');
|
|
|
|
let deferredPrompt = null;
|
|
|
|
function showBanner() {
|
|
if (isInStandalone || wasDismissed) return;
|
|
pwaInstallBanner.classList.remove('hidden');
|
|
}
|
|
|
|
// Chromium path (desktop Chrome/Edge, Android Chrome)
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
e.preventDefault();
|
|
deferredPrompt = e;
|
|
btnPwaInstall.classList.remove('hidden');
|
|
btnPwaInstall.textContent = 'Install';
|
|
showBanner();
|
|
});
|
|
|
|
window.addEventListener('appinstalled', () => {
|
|
pwaInstallBanner.classList.add('hidden');
|
|
deferredPrompt = null;
|
|
});
|
|
|
|
btnPwaInstall.addEventListener('click', async () => {
|
|
if (deferredPrompt) {
|
|
deferredPrompt.prompt();
|
|
try {
|
|
await deferredPrompt.userChoice;
|
|
} catch (_) { /* ignore */ }
|
|
deferredPrompt = null;
|
|
pwaInstallBanner.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
btnPwaClose.addEventListener('click', () => {
|
|
pwaInstallBanner.classList.add('hidden');
|
|
localStorage.setItem(dismissedKey, '1');
|
|
});
|
|
|
|
// Platform-specific manual fallbacks for browsers where
|
|
// `beforeinstallprompt` is either not supported or hasn't fired yet.
|
|
const isAndroid = /Android/i.test(ua);
|
|
const isFirefox = /Firefox|FxiOS/i.test(ua);
|
|
const isSamsung = /SamsungBrowser/i.test(ua);
|
|
const isChromiumMobile = isAndroid && /Chrome|CriOS|EdgA/i.test(ua) && !isFirefox && !isSamsung;
|
|
|
|
function showManualBanner(headline, instructions) {
|
|
if (isInStandalone || wasDismissed) return;
|
|
if (bannerTextEl) {
|
|
const h = bannerTextEl.querySelector('h4');
|
|
const p = bannerTextEl.querySelector('p');
|
|
if (h) h.textContent = headline;
|
|
if (p) p.textContent = instructions;
|
|
}
|
|
btnPwaInstall.classList.add('hidden');
|
|
showBanner();
|
|
}
|
|
|
|
if (isIos && !isInStandalone && !wasDismissed) {
|
|
// iOS Safari — Share → Add to Home Screen
|
|
setTimeout(() => {
|
|
showManualBanner(
|
|
'Add Tortugotchi to Home Screen',
|
|
'Tap the Share icon ↑ in Safari, then "Add to Home Screen" to play offline.'
|
|
);
|
|
}, 1500);
|
|
} else if (isFirefox && isAndroid && !isInStandalone && !wasDismissed) {
|
|
// Firefox Android — no beforeinstallprompt; user must use menu
|
|
setTimeout(() => {
|
|
showManualBanner(
|
|
'Install Tortugotchi',
|
|
'Tap the ⋮ menu in Firefox, then "Install" or "Add to Home Screen".'
|
|
);
|
|
}, 1500);
|
|
} else if (isSamsung && !isInStandalone && !wasDismissed) {
|
|
setTimeout(() => {
|
|
showManualBanner(
|
|
'Install Tortugotchi',
|
|
'Tap the ⋮ menu, then "Add page to" → "Home screen".'
|
|
);
|
|
}, 1500);
|
|
} else if (isChromiumMobile && !isInStandalone && !wasDismissed) {
|
|
// Chrome/Edge on Android — beforeinstallprompt SHOULD fire, but engagement
|
|
// heuristics or HTTP serving can suppress it. If it hasn't fired after a
|
|
// few seconds, fall back to manual instructions so the banner still
|
|
// appears.
|
|
setTimeout(() => {
|
|
if (!deferredPrompt && pwaInstallBanner.classList.contains('hidden')) {
|
|
showManualBanner(
|
|
'Install Tortugotchi',
|
|
'Tap the ⋮ menu in Chrome, then "Install app" or "Add to Home screen".'
|
|
);
|
|
}
|
|
}, 6000);
|
|
}
|
|
|
|
// Service Worker Registration
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('./sw.js')
|
|
.then((reg) => console.log('Service Worker registered successfully!', reg.scope))
|
|
.catch((err) => console.log('Service Worker registration failed:', err));
|
|
});
|
|
}
|
|
|
|
// ==========================================
|
|
// 4. TRANSITIONS & STATE MACHINE
|
|
// ==========================================
|
|
function changeState(newState) {
|
|
const outgoing = document.querySelector('.game-screen.active');
|
|
const incomingEl = screens[newState.split('-')[1]];
|
|
|
|
const finish = () => {
|
|
Object.values(screens).forEach(s => {
|
|
s.classList.remove('active', 'screen-exit', 'screen-enter');
|
|
});
|
|
currentState = newState;
|
|
incomingEl.classList.add('active');
|
|
// Force reflow so the enter animation actually plays
|
|
void incomingEl.offsetWidth;
|
|
incomingEl.classList.add('screen-enter');
|
|
setTimeout(() => incomingEl.classList.remove('screen-enter'), 600);
|
|
|
|
if (newState === STATES.BEACH) initBeachPhase();
|
|
else if (newState === STATES.OCEAN) initOceanPhase();
|
|
else if (newState === STATES.BEACHING) initBeachingPhase();
|
|
};
|
|
|
|
if (outgoing && outgoing !== incomingEl) {
|
|
outgoing.classList.add('screen-exit');
|
|
setTimeout(finish, 320);
|
|
} else {
|
|
finish();
|
|
}
|
|
}
|
|
|
|
// Initialize Beach Nest Phase
|
|
function initBeachPhase() {
|
|
hatchlingsSaved = 0;
|
|
beachTimeRemaining = 45;
|
|
txtHatchlingsSaved.textContent = "0 / 20";
|
|
beachHazardsLayer.innerHTML = '';
|
|
hatchlingCrawlersLayer.innerHTML = '';
|
|
beachProgressFill.style.width = "100%";
|
|
beachProgressFill.classList.remove('warning');
|
|
beachFeedbackText.textContent = "Guarding nest... Keep lights off and clear beach hazards!";
|
|
lightPollutionHud.classList.add('hidden');
|
|
|
|
// Species assignment text
|
|
const selectedSpeciesRadio = document.querySelector('input[name="species"]:checked');
|
|
turtleSpecies = selectedSpeciesRadio ? selectedSpeciesRadio.value : 'green';
|
|
|
|
// Clear existing beach intervals
|
|
clearInterval(beachTimer);
|
|
clearInterval(beachHazardsInterval);
|
|
clearInterval(beachHatchInterval);
|
|
|
|
// Beach Phase Timer (45 seconds total)
|
|
beachTimer = setInterval(() => {
|
|
beachTimeRemaining--;
|
|
const pct = (beachTimeRemaining / 45) * 100;
|
|
beachProgressFill.style.width = `${pct}%`;
|
|
|
|
if (beachTimeRemaining <= 15) {
|
|
beachProgressFill.classList.add('warning');
|
|
}
|
|
|
|
// Start Hatching during the last 20 seconds
|
|
if (beachTimeRemaining === 20) {
|
|
beachFeedbackText.textContent = "🥚 Hatching has begun! Help them reach the moonlit ocean!";
|
|
startHatchlingsCrawl();
|
|
}
|
|
|
|
if (beachTimeRemaining <= 0) {
|
|
endBeachPhase();
|
|
}
|
|
}, 1000);
|
|
|
|
// Hazard spawning cycle (every 3.5 seconds)
|
|
beachHazardsInterval = setInterval(() => {
|
|
if (beachTimeRemaining > 15) {
|
|
spawnBeachHazard();
|
|
} else {
|
|
// Higher intensity during crawling phase
|
|
if (Math.random() > 0.4) spawnBeachHazard();
|
|
}
|
|
}, 3500);
|
|
}
|
|
|
|
// Initialize Ocean Rearing Phase
|
|
function initOceanPhase() {
|
|
// Clear any beach intervals
|
|
clearInterval(beachTimer);
|
|
clearInterval(beachHazardsInterval);
|
|
clearInterval(beachHatchInterval);
|
|
|
|
// Reset ocean stats if it's a new game (Age = 0)
|
|
if (turtleAge === 0) {
|
|
if (startWithBonus) {
|
|
stats = { health: 100, hunger: 100, joy: 100, clean: 100 };
|
|
} else {
|
|
stats = { health: 100, hunger: 80, joy: 80, clean: 100 };
|
|
}
|
|
isSick = false;
|
|
isHidingInShell = false;
|
|
startWithBonus = false; // Reset the flag
|
|
}
|
|
|
|
// Set Species details in HUD
|
|
const speciesNames = {
|
|
green: "Green Sea Turtle",
|
|
loggerhead: "Loggerhead Sea Turtle",
|
|
leatherback: "Leatherback Sea Turtle"
|
|
};
|
|
displayTurtleSpecies.textContent = speciesNames[turtleSpecies] || "Sea Turtle";
|
|
displayTurtleName.textContent = turtleName;
|
|
turtleAgeVal.textContent = formatAge(turtleAge);
|
|
applySpeciesAppearance();
|
|
applyGrowthStage();
|
|
|
|
updateStatBars();
|
|
setEyeState('normal');
|
|
setMouthState('normal');
|
|
|
|
// Core loops
|
|
startOceanLoops();
|
|
}
|
|
|
|
function startOceanLoops() {
|
|
clearOceanLoops();
|
|
|
|
// 1. Stats decay — idle-game pace. A healthy turtle lasts many hours
|
|
// between feedings, so players can come back across days/weeks.
|
|
// Decay tick is still 1s for smooth bars but rates are tuned for hours.
|
|
oceanLoops.decay = setInterval(() => {
|
|
if (activeQte) return;
|
|
|
|
// ~0.15/s hunger ≈ depletes from 100→0 in ~11 minutes of active play,
|
|
// but with offline catch-up capped at 24h of decay, a fed turtle will
|
|
// still be alive when the player returns the next day.
|
|
stats.hunger = Math.max(0, stats.hunger - 0.15);
|
|
stats.joy = Math.max(0, stats.joy - 0.18);
|
|
stats.clean = Math.max(0, stats.clean - 0.08);
|
|
|
|
// Health only suffers under prolonged neglect (any stat near zero)
|
|
let penaltyCount = 0;
|
|
if (stats.hunger < 20) penaltyCount++;
|
|
if (stats.joy < 20) penaltyCount++;
|
|
if (stats.clean < 25) penaltyCount++;
|
|
|
|
if (penaltyCount > 0) {
|
|
stats.health = Math.max(0, stats.health - (penaltyCount * 0.4));
|
|
setMouthState('sad');
|
|
if (Math.random() > 0.85) {
|
|
triggerThoughtBubble(getRandomLowStatThought());
|
|
}
|
|
} else {
|
|
if (stats.hunger > 60 && stats.joy > 60 && stats.clean > 60) {
|
|
stats.health = Math.min(100, stats.health + 0.15);
|
|
}
|
|
setMouthState('normal');
|
|
}
|
|
|
|
// Cleanliness visuals (algae visibility)
|
|
if (stats.clean < 50) {
|
|
algaeSpots.classList.remove('hidden');
|
|
} else {
|
|
algaeSpots.classList.add('hidden');
|
|
}
|
|
|
|
// Check for sickness from eating plastic
|
|
if (isSick) {
|
|
stats.health = Math.max(0, stats.health - 2);
|
|
setEyeState('dizzy');
|
|
if (Math.random() > 0.8) triggerThoughtBubble("Ouch, tummy hurts... 🩺");
|
|
}
|
|
|
|
updateStatBars();
|
|
checkGameOver();
|
|
}, 1000);
|
|
|
|
// 2. Aging — sea turtle ages in proportion to how healthy/cared-for it is.
|
|
// A neglected turtle barely grows; a thriving one ages quickly and reaches
|
|
// adulthood over weeks of real-world play.
|
|
// Tick every 10s; ageProgress accumulates fractional days until it hits 1.
|
|
let ageProgress = 0;
|
|
oceanLoops.age = setInterval(() => {
|
|
if (activeQte) return;
|
|
const gained = computeAgeGain(10);
|
|
ageProgress += gained;
|
|
|
|
if (ageProgress >= 1) {
|
|
const wholeDays = Math.floor(ageProgress);
|
|
ageProgress -= wholeDays;
|
|
turtleAge += wholeDays;
|
|
turtleAgeVal.textContent = formatAge(turtleAge);
|
|
applyGrowthStage();
|
|
if (wholeDays > 0 && Math.random() > 0.7) {
|
|
triggerThoughtBubble(getGrowthThought(), 2200);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
changeState(STATES.BEACHING);
|
|
}
|
|
}, 10000);
|
|
|
|
// 3. Debris Generator — gentle idle pace
|
|
oceanLoops.debris = setInterval(() => {
|
|
if (activeQte || currentState !== STATES.OCEAN) return;
|
|
if (Math.random() > 0.55) {
|
|
spawnFloatingDebris();
|
|
}
|
|
}, 18000);
|
|
|
|
// 4. Random QTE Hazards — frequent rhythm. Roughly one hazard every ~45s
|
|
// on average (75% chance per 60s tick). Ignoring them is the main neglect
|
|
// risk in idle mode.
|
|
oceanLoops.hazards = setInterval(() => {
|
|
if (activeQte || currentState !== STATES.OCEAN) return;
|
|
if (Math.random() < 0.75) {
|
|
triggerQteHazard();
|
|
}
|
|
}, 60000);
|
|
|
|
// 5. Idle behaviors — turtle does cute things when not being interacted with
|
|
oceanLoops.idle = setInterval(() => {
|
|
if (activeQte || currentState !== STATES.OCEAN) return;
|
|
triggerIdleBehavior();
|
|
}, 4500);
|
|
|
|
// 6. Ambient bubble stream
|
|
oceanLoops.bubbles = setInterval(() => {
|
|
if (currentState !== STATES.OCEAN) return;
|
|
spawnAmbientBubble();
|
|
}, 1200);
|
|
}
|
|
|
|
function clearOceanLoops() {
|
|
Object.values(oceanLoops).forEach(loop => clearInterval(loop));
|
|
}
|
|
|
|
// ==========================================
|
|
// 5. LOCAL STORAGE SAVING/LOADING
|
|
// ==========================================
|
|
function saveProgress() {
|
|
const saveData = {
|
|
name: turtleName,
|
|
species: turtleSpecies,
|
|
age: turtleAge,
|
|
stats: stats,
|
|
isSick: isSick,
|
|
savedAt: Date.now()
|
|
};
|
|
localStorage.setItem('tortugotchi_save', JSON.stringify(saveData));
|
|
}
|
|
|
|
// Save before the tab/window closes so progress is captured even between
|
|
// 10-second auto-save ticks.
|
|
window.addEventListener('pagehide', saveProgress);
|
|
window.addEventListener('beforeunload', saveProgress);
|
|
|
|
const ALLOWED_SPECIES = ['green', 'loggerhead', 'leatherback'];
|
|
function clampStat(n) {
|
|
const v = Number(n);
|
|
if (!Number.isFinite(v)) return 0;
|
|
return Math.max(0, Math.min(100, v));
|
|
}
|
|
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(/[ |