thronglets / index.html
kimhyunwoo's picture
Update index.html
0563b4d verified
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Thronglets Simulation (v3)</title>
<style>
:root {
--grass-dark: #2a6141; --grass-light: #3a815b; --water: #4171a7;
--rock-dark: #6b727c; --tree-leaves: #2b602c; --ui-bg: #e0cda9;
--ui-border: #8b4513; --ui-accent: #6d8c4f; --ui-text: #3a2e20;
}
body {
margin: 0; overflow: hidden; background: var(--grass-dark);
color: #eee; font-family: 'Verdana', sans-serif; display: flex;
flex-direction: column; align-items: center; justify-content: center;
min-height: 100vh; position: relative;
}
.game-container {
position: relative; width: 95vmin; height: 70vmin;
max-width: 1000px; max-height: 700px;
background-color: var(--grass-light); /* Simple background color */
border: 1px solid var(--tree-trunk); box-sizing: border-box;
overflow: hidden; display: flex; justify-content: center;
align-items: center; font-size: 1.5em; position: relative;
image-rendering: pixelated; box-shadow: inset 0 0 20px rgba(0,0,0,0.4);
}
.game-elements {
position: absolute; top: 0; left: 0; width: 100%; height: 100%;
pointer-events: none;
}
.game-elements .emoji {
position: absolute; pointer-events: auto; transform: translate(-50%, -50%);
user-select: none; z-index: 1; transition: opacity 0.5s ease-out;
image-rendering: pixelated;
}
/* Static elements */
.rock-cluster { position: absolute; top: 30%; left: 25%; z-index: 0; font-size: 1.2em; color: var(--rock-dark); }
.river { position: absolute; top: 0; left: 35%; width: 8%; height: 100%; background: var(--water); z-index: 0; }
.tree { position: absolute; z-index: 0; font-size: 1.8em; color: var(--tree-leaves);}
.tree.t1 { top: 10%; left: 10%; } .tree.t2 { top: 15%; left: 55%; }
.tree.t3 { top: 60%; left: 85%; } .tree.t4 { top: 70%; left: 15%; }
.tree.t5 { top: 5%; left: 80%; }
/* Dynamic elements */
.egg { top: 50%; left: 50%; cursor: pointer; transition: transform 0.5s ease, opacity 0.5s ease-out; z-index: 2;}
.egg.hatching { animation: hatch-pulse 0.5s infinite alternate; }
.thronglet {
cursor: default;
transition: top 1s linear, left 1s linear, transform 0.2s ease, opacity 0.5s ease-out, filter 0.3s ease-out;
z-index: 3;
font-family: 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', Times, Symbola, Aegyptus, Demo;
}
.feedback {
position: absolute; transform: translate(-50%, -150%); font-size: 0.8em;
opacity: 0; transition: opacity 0.5s ease-out, transform 0.5s ease-out;
pointer-events: none; z-index: 5; text-shadow: 1px 1px 1px rgba(0,0,0,0.5);
}
.feedback.active { opacity: 1; transform: translate(-50%, -200%); }
.dead { filter: grayscale(100%); opacity: 0.4; pointer-events: none; z-index: 1; }
.blood {
position: absolute; font-size: 1.2em; transform: translate(-50%, -50%); pointer-events: none;
opacity: 0; transition: opacity 0.5s ease-out; z-index: 0; color: #a03030;
}
.blood.splatter { opacity: 0.7; }
/* --- UI Panels --- */
.ui-panel-container {
position: absolute; top: 15px; left: 15px; right: 15px;
display: flex; justify-content: space-between;
pointer-events: none; z-index: 10;
}
.ui-panel {
background: var(--ui-bg); border: 3px solid var(--ui-border);
border-radius: 8px; box-shadow: 3px 3px 5px rgba(0,0,0,0.3);
padding: 5px; pointer-events: auto; image-rendering: pixelated;
}
/* Top Left UI Panel */
#topLeftUI { display: flex; flex-direction: column; align-items: center; width: 70px; }
.logo { /* ... Same style ... */
background: var(--ui-accent); border: 2px solid var(--ui-border); color: var(--ui-bg);
font-size: 1.8em; font-weight: bold; width: 40px; height: 40px; display: flex;
align-items: center; justify-content: center; border-radius: 50%; margin-bottom: 5px;
}
.action-grid { /* ... Same style ... */
display: grid; grid-template-columns: 1fr 1fr; gap: 4px; width: 100%;
}
.action-grid button { /* ... Same style ... */
background: var(--ui-bg); border: 2px solid var(--ui-border); color: var(--ui-text);
font-size: 1.2em; width: 30px; height: 30px; display: flex; align-items: center;
justify-content: center; border-radius: 4px; cursor: pointer;
transition: background-color 0.2s, transform 0.1s, border-color 0.1s; padding: 0; line-height: 1;
}
.action-grid button.selected { border-color: #fff; box-shadow: inset 0 0 3px rgba(0,0,0,0.4); }
.action-grid button:hover { background-color: #f5e5c5; }
.action-grid button:active { transform: scale(0.95); }
/* Button IDs */
#pointerBtn { grid-column: 1; grid-row: 1; } #targetBtn { grid-column: 2; grid-row: 1; }
#feedButton { grid-column: 1; grid-row: 2; } #cleanButton { grid-column: 2; grid-row: 2; }
#brainBtn { grid-column: 1; grid-row: 3; } #plantBtn { grid-column: 2; grid-row: 3; }
#houseBtn { grid-column: 1; grid-row: 4; } #gearBtn { grid-column: 2; grid-row: 4; }
/* Top Right UI Panel */
#topRightUI { display: flex; flex-direction: column; align-items: center; width: 80px; }
.thronglet-icon-display {
background: var(--ui-accent); border: 2px solid var(--ui-border); width: 50px; height: 50px;
border-radius: 50%; display: flex; align-items: center; justify-content: center;
font-size: 2em; /* Make single emoji larger */
margin-bottom: 4px; color: var(--ui-bg);
font-family: 'Noto Color Emoji', 'Apple Color Emoji', 'Segoe UI Emoji', Times, Symbola, Aegyptus, Demo; /* Ensure emoji font */
}
/* Removed inner spans for multiple icons */
#throngletCountDisplayTopRight { color: var(--ui-text); font-weight: bold; font-size: 1.1em; }
/* Hidden elements */
.scanline-overlay, .glitch-overlay, .ominous-elements, .info-panel,
.final-screen, .full-black, .netflix-games-logo {
opacity: 0; pointer-events: none; display: none;
}
.visible { opacity: 1 !important; pointer-events: auto !important; display: flex !important; }
.ominous-elements.visible, .info-panel.visible { display: block !important; }
@keyframes hatch-pulse { /* Unchanged */
from { transform: translate(-50%, -50%) scale(1); }
to { transform: translate(-50%, -50%) scale(1.05); }
}
</style>
</head>
<body>
<div class="game-container" id="gameContainer">
<div class="game-elements" id="gameElements">
<!-- Static Elements -->
<div class="river"></div> <div class="rock-cluster">πŸͺ¨<br>πŸͺ¨πŸͺ¨</div>
<span class="emoji tree t1">🌳</span> <span class="emoji tree t2">🌳</span>
<span class="emoji tree t3">🌳</span> <span class="emoji tree t4">🌳</span>
<span class="emoji tree t5">🌳</span>
<!-- Initial dynamic elements -->
<span class="emoji egg" id="egg">πŸ₯š</span>
<!-- Hidden Elements -->
<div class="ominous-elements" id="ominousElements">πŸ’€<br>🦴<br>πŸŒŒπŸ“¦</div>
<div class="info-panel" id="infoPanel"></div>
<span class="emoji blood" id="bloodSplatter">🩸</span>
</div>
<!-- UI Panels -->
<div class="ui-panel-container">
<div class="ui-panel" id="topLeftUI">
<div class="logo">T</div>
<div class="action-grid" id="actionGrid">
<button id="pointerBtn" title="Pointer">βœ‹</button>
<button id="targetBtn" title="Target">🎯</button>
<button id="feedButton" title="Feed">🍎</button>
<button id="cleanButton" title="Clean">🧼</button>
<button id="brainBtn" title="Mood?">🧠</button>
<button id="plantBtn" title="Plant?">🌱</button>
<button id="houseBtn" title="Build?">🏠</button>
<button id="gearBtn" title="Settings?">βš™οΈ</button>
</div>
</div>
<div class="ui-panel" id="topRightUI">
<div class="thronglet-icon-display">🐹</div> <!-- Simplified Icon -->
<span id="throngletCountDisplayTopRight">0</span>
</div>
</div>
</div>
<!-- Hidden Overlays/Final Screens -->
<div class="scanline-overlay" id="scanlineOverlay"></div>
<div class="glitch-overlay" id="glitchOverlay"></div>
<div class="final-screen" id="finalScreen">END</div>
<div class="full-black" id="fullBlack"></div>
<div class="netflix-games-logo" id="netflixGamesLogo"><span class="emoji">N</span></div>
<script>
// --- DOM Elements ---
const egg = document.getElementById('egg');
const gameContainer = document.getElementById('gameContainer');
const gameElements = document.getElementById('gameElements');
const actionGrid = document.getElementById('actionGrid');
const throngletCountDisplay = document.getElementById('throngletCountDisplayTopRight');
const infoPanel = document.getElementById('infoPanel');
const bloodSplatter = document.getElementById('bloodSplatter');
// --- Game State ---
let thronglets = [];
let nextThrongletId = 0;
let deathCount = 0;
const MAX_THRONGLETS = 100;
let gameInterval;
let gameActive = true;
let selectedTool = 'pointer';
// --- Web Audio API Setup ---
let audioContext;
let isAudioContextResumed = false;
// initAudio and playSound functions (same as previous version)
function initAudio() {
if (isAudioContextResumed) return; // Already resumed
if (!audioContext) {
try {
window.AudioContext = window.AudioContext || window.webkitAudioContext;
audioContext = new AudioContext();
} catch (e) { console.error("Web Audio API not supported", e); return; }
}
// Resume on first user interaction if needed
if (audioContext.state === 'suspended') {
const resumeAudio = () => {
if (audioContext.state === 'suspended') {
audioContext.resume().then(() => {
isAudioContextResumed = true; console.log("AudioContext Resumed");
document.body.removeEventListener('click', resumeAudio);
document.body.removeEventListener('touchend', resumeAudio);
}).catch(e => console.error("Audio resume failed", e));
} else { // Already running or closed?
isAudioContextResumed = (audioContext.state === 'running');
document.body.removeEventListener('click', resumeAudio);
document.body.removeEventListener('touchend', resumeAudio);
}
};
// Use capture phase to potentially catch earlier interactions
document.body.addEventListener('click', resumeAudio, { once: true, capture: true });
document.body.addEventListener('touchend', resumeAudio, { once: true, capture: true });
} else {
isAudioContextResumed = true; // Already running
}
}
function playSound(type) {
if (!audioContext || !isAudioContextResumed) {
console.log("Audio not ready, skipping sound:", type);
// Attempt to resume if called before interaction
if (audioContext && audioContext.state === 'suspended') initAudio();
return;
}
const osc = audioContext.createOscillator(); const gain = audioContext.createGain(); osc.connect(gain); gain.connect(audioContext.destination);
const now = audioContext.currentTime; let freq = 440, duration = 0.1, vol = 0.2; osc.type = 'sine';
switch (type) { /* ... cases same as before ... */
case 'hatch': freq = 660; duration = 0.3; vol = 0.3; osc.type = 'triangle'; gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(vol, now + 0.05); gain.gain.linearRampToValueAtTime(0, now + duration); break;
case 'feed': freq = 880; duration = 0.08; vol = 0.15; gain.gain.setValueAtTime(vol, now); gain.gain.linearRampToValueAtTime(0, now + duration); break;
case 'clean': freq = 1100; duration = 0.1; vol = 0.1; osc.type = 'square'; gain.gain.setValueAtTime(vol, now); gain.gain.linearRampToValueAtTime(0, now + duration); break;
case 'death': freq = 110; duration = 0.8; vol = 0.4; osc.type = 'sawtooth'; gain.gain.setValueAtTime(vol, now); gain.gain.exponentialRampToValueAtTime(0.01, now + duration); osc.frequency.setValueAtTime(freq, now); osc.frequency.exponentialRampToValueAtTime(55, now + duration); break;
case 'duplicate': freq = 523; duration = 0.4; vol = 0.25; osc.type = 'triangle'; gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(vol, now + 0.05); osc.frequency.setValueAtTime(freq, now); osc.frequency.linearRampToValueAtTime(freq * 1.5, now + duration * 0.8); gain.gain.linearRampToValueAtTime(0, now + duration); break;
case 'feedback': freq = 1320; duration = 0.05; vol = 0.08; gain.gain.setValueAtTime(vol, now); gain.gain.linearRampToValueAtTime(0, now + duration); break;
case 'error': freq = 220; duration = 0.2; vol = 0.2; osc.type = 'square'; gain.gain.setValueAtTime(vol, now); gain.gain.linearRampToValueAtTime(0, now + duration); break;
case 'ui_click': freq = 1000; duration = 0.04; vol = 0.05; osc.type = 'square'; gain.gain.setValueAtTime(vol, now); gain.gain.linearRampToValueAtTime(0, now + duration); break;
default: freq = 900; duration = 0.05; vol = 0.1; gain.gain.setValueAtTime(vol, now); gain.gain.linearRampToValueAtTime(0, now + duration);
}
osc.frequency.setValueAtTime(freq, now); osc.start(now); osc.stop(now + duration);
}
// --- Game Logic ---
function updateUI() {
const livingCount = thronglets.filter(t => !t.isDead).length;
throngletCountDisplay.textContent = livingCount;
}
function showInfoPanel(text, duration = 2000) {
if (!gameActive || !infoPanel) return;
infoPanel.textContent = text;
infoPanel.style.display = 'block';
requestAnimationFrame(() => { // Ensure display:block is applied before opacity transition starts
infoPanel.style.opacity = 1;
});
clearTimeout(infoPanel.timeout);
infoPanel.timeout = setTimeout(() => {
infoPanel.style.opacity = 0;
// Use transitionend event listener for reliable hiding
const hidePanel = () => { infoPanel.style.display = 'none'; };
infoPanel.addEventListener('transitionend', hidePanel, { once: true });
}, duration);
}
function createThronglet(xPercent, yPercent) {
const livingCount = thronglets.filter(t => !t.isDead).length;
if (!gameActive || livingCount >= MAX_THRONGLETS) {
if (livingCount >= MAX_THRONGLETS) {
playSound('error'); showInfoPanel("Population Limit!", 1500);
} return;
}
const throngletElement = document.createElement('span');
throngletElement.classList.add('emoji', 'thronglet');
throngletElement.textContent = '🐹'; // Hamster emoji
const currentId = nextThrongletId++;
throngletElement.dataset.id = currentId;
const spawnX = Math.max(5, Math.min(95, xPercent));
const spawnY = Math.max(5, Math.min(95, yPercent));
throngletElement.style.top = `${spawnY}%`; throngletElement.style.left = `${spawnX}%`;
throngletElement.dataset.bornTime = Date.now();
gameElements.appendChild(throngletElement);
const newThronglet = { id: currentId, element: throngletElement, hunger: 25, cleanliness: 20, happiness: 70, lastInteraction: Date.now(), lastDuplication: 0, isDead: false, memory: [], feedbackElement: null };
thronglets.push(newThronglet);
const feedbackElement = document.createElement('span');
feedbackElement.classList.add('emoji', 'feedback');
feedbackElement.style.top = throngletElement.style.top; feedbackElement.style.left = throngletElement.style.left;
gameElements.appendChild(feedbackElement); newThronglet.feedbackElement = feedbackElement;
updateUI();
setTimeout(() => { if (!newThronglet.isDead) wander(newThronglet); }, 100);
}
function feedThronglet(thronglet) {
if (!gameActive || thronglet.isDead) return;
thronglet.hunger = Math.max(0, thronglet.hunger - 50); thronglet.happiness = Math.min(100, thronglet.happiness + 8);
thronglet.lastInteraction = Date.now(); showFeedback(thronglet, 'πŸ˜‹'); playSound('feed'); thronglet.memory.push('Fed');
}
function cleanThronglet(thronglet) {
if (!gameActive || thronglet.isDead) return;
thronglet.cleanliness = Math.max(0, thronglet.cleanliness - 60); thronglet.happiness = Math.min(100, thronglet.happiness + 4);
thronglet.lastInteraction = Date.now(); showFeedback(thronglet, '✨'); playSound('clean'); thronglet.memory.push('Cleaned');
}
function showFeedback(thronglet, emoji) {
if (!gameActive || !thronglet.feedbackElement || thronglet.isDead) return;
thronglet.feedbackElement.style.top = thronglet.element.style.top; thronglet.feedbackElement.style.left = thronglet.element.style.left;
thronglet.feedbackElement.textContent = emoji; thronglet.feedbackElement.classList.add('active'); playSound('feedback');
clearTimeout(thronglet.feedbackElement.timeout);
thronglet.feedbackElement.timeout = setTimeout(() => { if (thronglet.feedbackElement) { thronglet.feedbackElement.classList.remove('active'); } }, 600);
}
function killThronglet(thronglet, reason = "expiration") {
if (!gameActive || thronglet.isDead) return;
thronglet.isDead = true; thronglet.element.classList.add('dead'); thronglet.element.textContent = 'πŸ’€';
if (thronglet.feedbackElement) { thronglet.feedbackElement.remove(); thronglet.feedbackElement = null; }
bloodSplatter.style.top = thronglet.element.style.top; bloodSplatter.style.left = thronglet.element.style.left;
bloodSplatter.classList.add('splatter'); setTimeout(() => { bloodSplatter.classList.remove('splatter'); }, 800);
deathCount++; updateUI(); playSound('death'); thronglet.memory.push(`Died (${reason})`);
setTimeout(() => { if (thronglet.element) { thronglet.element.style.opacity = 0; setTimeout(() => { if(thronglet.element) thronglet.element.remove(); }, 500); } }, 3000);
}
function wander(thronglet) {
if (!gameActive || thronglet.isDead) return;
const moveDist = 0.8; // Further reduced movement distance
const currentX = parseFloat(thronglet.element.style.left); const currentY = parseFloat(thronglet.element.style.top);
const newX = Math.max(5, Math.min(95, currentX + (Math.random() - 0.5) * moveDist * 2));
const newY = Math.max(5, Math.min(95, currentY + (Math.random() - 0.5) * moveDist * 2));
thronglet.element.style.left = `${newX}%`; thronglet.element.style.top = `${newY}%`;
if (thronglet.feedbackElement) { thronglet.feedbackElement.style.left = `${newX}%`; thronglet.feedbackElement.style.top = `${newY}%`; }
}
function updateThronglets() {
if (!gameActive) return;
const now = Date.now();
const livingThronglets = thronglets.filter(t => !t.isDead);
livingThronglets.forEach(thronglet => {
const timeSinceLast = now - thronglet.lastInteraction;
const baseNeedRate = 0.15; const neglectMultiplier = 1 + Math.min(5, timeSinceLast / 10000);
thronglet.hunger = Math.min(100, thronglet.hunger + baseNeedRate * neglectMultiplier * 1.0);
thronglet.cleanliness = Math.min(100, thronglet.cleanliness + baseNeedRate * neglectMultiplier * 0.8);
thronglet.happiness = Math.max(0, thronglet.happiness - baseNeedRate * neglectMultiplier * 1.1);
let deathReason = null;
if (thronglet.hunger >= 100) deathReason = "starvation"; else if (thronglet.cleanliness >= 100) deathReason = "filth"; else if (thronglet.happiness <= 0) deathReason = "misery";
if (deathReason) { killThronglet(thronglet, deathReason); return; }
// Duplication Logic
const canDuplicate = thronglet.happiness > 85 && thronglet.hunger < 15 && thronglet.cleanliness < 15 && timeSinceLast < 15000 && (now - thronglet.lastDuplication > 20000);
if (canDuplicate && Math.random() < 0.015) {
showFeedback(thronglet, 'πŸ’ž'); playSound('duplicate'); thronglet.lastDuplication = now; thronglet.lastInteraction = now;
setTimeout(() => {
const parentX = parseFloat(thronglet.element.style.left); const parentY = parseFloat(thronglet.element.style.top);
createThronglet(parentX + (Math.random() - 0.5) * 5, parentY + (Math.random() - 0.5) * 5);
}, 500);
}
// Wander even less often
if (Math.random() < 0.15) { wander(thronglet); }
});
if (Math.random() < 0.2) updateUI(); // Update UI count periodically
}
// --- Tool Selection Function ---
function selectTool(toolButtonElement) {
// Get the ID to store the tool name
selectedTool = toolButtonElement.id.replace(/Btn|Button/g, ''); // Get base name like 'pointer', 'feed'
playSound('ui_click');
// Remove 'selected' class from all buttons in the grid
actionGrid.querySelectorAll('button').forEach(btn => btn.classList.remove('selected'));
// Add 'selected' class to the clicked button
toolButtonElement.classList.add('selected');
// console.log("Selected tool:", selectedTool);
}
// --- Event Handlers ---
egg.addEventListener('click', () => {
if (!gameActive || egg.classList.contains('hatching') || !document.contains(egg)) return;
initAudio(); // IMPORTANT: Call initAudio on first interaction!
egg.classList.add('hatching'); playSound('hatch');
setTimeout(() => {
if(document.contains(egg)) egg.remove();
createThronglet(45, 50); createThronglet(55, 50); createThronglet(50, 45);
}, 1000);
});
// Centralized Action Grid Listener
actionGrid.addEventListener('click', (event) => {
if (event.target.tagName === 'BUTTON') {
const buttonElement = event.target;
const buttonId = buttonElement.id;
initAudio(); // Ensure audio context is active
// Select the tool visually first
selectTool(buttonElement);
// Perform immediate action for feed/clean
if (buttonId === 'feedButton') {
const living = thronglets.filter(t => !t.isDead);
if (living.length > 0) {
// Use reduce with initial value null to handle empty array
const target = living.reduce((p, c) => (p === null || c.hunger > p.hunger) ? c : p, null);
if (target) { // Check if a target was found
feedThronglet(target);
} else {
// This case shouldn't happen if living.length > 0, but good practice
playSound('error');
}
} else {
playSound('error'); // No living thronglets
}
} else if (buttonId === 'cleanButton') {
const living = thronglets.filter(t => !t.isDead);
if (living.length > 0) {
// Use reduce with initial value null
const target = living.reduce((p, c) => (p === null || c.cleanliness > p.cleanliness) ? c : p, null);
if (target) { // Check if target found
cleanThronglet(target);
} else {
playSound('error');
}
} else {
playSound('error'); // No living thronglets
}
} else if (buttonId !== 'pointerBtn') {
// Placeholder message/sound for other tools for now
console.log(`${selectedTool} tool selected (no action implemented)`);
// Optionally play a generic 'select' sound different from 'ui_click'
}
}
});
// --- Initial Setup ---
function initGame() {
gameActive = true; updateUI();
gameInterval = setInterval(updateThronglets, 400);
// Attempt to initialize audio context, but it requires user interaction first
// It will be properly initialized/resumed on the first click (e.g., egg click)
initAudio();
selectTool(document.getElementById('pointerBtn')); // Set pointer as default selected tool visually
console.log("Game Initialized. Click the egg.");
}
initGame(); // Start the game
</script>
</body>
</html>