SYMBIOTE
0
Move to a host to absorb

CONTAINED

Score: 0

"tune": [ { "id": "player_speed", "label": "Player Speed", "type": "slider", "min": 2, "max": 10, "step": 0.5, "value": 5, "path": "appState.config.playerSpeed" }, { "id": "enemy_spawn_rate", "label": "Enemy Spawn Rate", "type": "slider", "min": 500, "max": 3000, "step": 100, "value": 1500, "path": "appState.config.spawnRate" } ], "images": [ { "id": "img_symbiote", "label": "Symbiote Texture", "type": "image", "value": "https://prod-data.sekai.chat/aiu-character/user-inserted/7572825/571c9e78-c69e-4bcf-946b-e009f4aac437_body.png?AWSAccessKeyId=AKIAQE43KJDNQNXWXTOG&Signature=pqXxdpZoflpSLLWA1YAYve%2FiCTg%3D&Expires=1772457664", "description": "black alien blob symbiote texture" }, { "id": "img_civ", "label": "Civilian Host", "type": "image", "value": "https://prod-data.sekai.chat/aiu-character/user-inserted/4821618/332860cc-9606-4828-be48-e7548a677023_body.png?AWSAccessKeyId=AKIAQE43KJDNQNXWXTOG&Signature=rzZ%2BypBu4HsiAIYT1c121obItzY%3D&Expires=1772272530", "description": "scared civilian person top down" }, { "id": "img_brute", "label": "Brute Host", "type": "image", "value": "https://prod-data.sekai.chat/aiu-asset-image/import/viking_weightlifter.png", "description": "muscular tough guy fighter top down" }, { "id": "img_enemy", "label": "Enemy Soldier", "type": "image", "value": "https://prod-data.sekai.chat/v3-games/chat-creation/fdf4f501-77d8-49ab-8fb0-ae768d62bec8.webp", "description": "armed tactical soldier top down" }, { "id": "bg_floor", "label": "Arena Floor", "type": "image", "value": "https://prod-data.sekai.chat/chat-creation/a4ddebbd-8d2d-4d64-be0e-22affea3c7f9.webp", "description": "dark cracked asphalt texture", "selector": "#game-canvas", "property": "backgroundImage" } ], "videos": [], "music": [ { "id": "bgm", "label": "Background Music", "type": "audio", "value": "https://prod-data.sekai.chat/chat-creation/c12e625c-49ab-4133-bd9c-4b797f50aa39.mp3", "name": "Dark Action Synth", "description": "dark synthwave action battle music", "selector": "#music-bgm" } ], "sfx": [ { "id": "sfx_absorb", "label": "Absorb Sound", "type": "audio", "value": "https://prod-data.sekai.chat/aiu-music-sfx/54/sound_of_slicing_meat_xh_01_8e09f484e99a1720c1f505075ff1c58f.mp3", "name": "Squish Attach", "description": "fleshy squish attach sound", "selector": "#sfx-absorb" }, { "id": "sfx_attack", "label": "Attack Sound", "type": "audio", "value": "https://prod-data.sekai.chat/aiu-music-sfx/107/monster_attack_sound_1_xh_8362d03d82bf2e6bcd6b76e5c6380cb6.mp3", "name": "Heavy Hit", "description": "heavy punch impact sound", "selector": "#sfx-attack" }, { "id": "sfx_eject", "label": "Eject Sound", "type": "audio", "value": "https://prod-data.sekai.chat/aiu-music-sfx/54/sound_of_slicing_meat_xh_02_a3480bcef329b2c135a729ecd32d919a.mp3", "name": "Flesh Tear", "description": "flesh tear explosion sound", "selector": "#sfx-eject" } ], "colors": [ { "id": "col_symbiote", "label": "Symbiote Color", "type": "color", "cssVar": "--symbiote-color", "value": "#1a1a2e" }, { "id": "col_accent", "label": "Accent Color", "type": "color", "cssVar": "--accent-color", "value": "#e94560" } ], "text": [], "prompts": [], "voices": [] }; // Global State required for paths window.appState = { config: { playerSpeed: 5, spawnRate: 1500 } }; // --- Helper Functions --- function getConfig(category, id) { return window.sekaiEditable[category]?.find(i => i.id === id)?.value; } function setNestedValue(obj, path, value) { const parts = path.split('.'); let current = obj; for (let i = 0; i < parts.length - 1; i++) { if (!current[parts[i]]) current[parts[i]] = {}; current = current[parts[i]]; } current[parts[parts.length - 1]] = value; } function applyAllEditableValues() { window.sekaiEditable.tune?.forEach(t => { if (t.path) setNestedValue(window, t.path, t.value); }); window.sekaiEditable.colors?.forEach(c => { if (c.cssVar) document.documentElement.style.setProperty(c.cssVar, c.value); }); window.sekaiEditable.images?.forEach(img => { if (img.selector) { const el = document.querySelector(img.selector); if (el) { if (img.property === 'src') el.src = img.value; else if (img.property === 'backgroundImage') el.style.backgroundImage = img.value ? `url('${img.value}')` : ''; } } }); window.sekaiEditable.music?.forEach(m => { if (m.selector) { const el = document.querySelector(m.selector); if (el && el.src !== m.value) { el.src = m.value; el.load(); } } }); window.sekaiEditable.sfx?.forEach(s => { if (s.selector) { const el = document.querySelector(s.selector); if (el && el.src !== s.value) { el.src = s.value; el.load(); } } }); } // --- Preview Editing API --- (function setupPreviewEditingAPI() { window.addEventListener('message', (event) => { const msg = event.data; if (msg?.origin !== 'sekai_gaming_iframe_api') return; switch (msg.type) { case 'get_editable_metadata': window.parent.postMessage({ origin: 'sekai_gaming_iframe_api', type: 'receive_editable_metadata', taskId: msg.taskId, data: window.sekaiEditable || {} }, '*'); break; case 'apply_change': const success = applySingleChange(msg.data.id, msg.data.value, msg.data.name, msg.data.description); window.parent.postMessage({ origin: 'sekai_gaming_iframe_api', type: 'receive_apply_change', taskId: msg.taskId, data: { success } }, '*'); break; case 'get_current_html': window.parent.postMessage({ origin: 'sekai_gaming_iframe_api', type: 'receive_current_html', taskId: msg.taskId, data: { html: document.documentElement.outerHTML } }, '*'); break; case 'reset_changes': location.reload(); break; } }); function applySingleChange(id, value, name, description) { const categories = ['tune', 'images', 'videos', 'music', 'sfx', 'colors', 'text', 'prompts', 'voices']; for (const cat of categories) { const item = window.sekaiEditable[cat]?.find(i => i.id === id); if (!item) continue; item.value = value; if (name) item.name = name; if (description) item.description = description; if (item.cssVar) { document.documentElement.style.setProperty(item.cssVar, value); } else if (item.path) { setNestedValue(window, item.path, value); } else if (item.selector) { const el = document.querySelector(item.selector); if (!el) return false; if (el.tagName === 'AUDIO') { const wasPlaying = !el.paused; el.src = value; el.load(); if (wasPlaying) el.play().catch(() => {}); } else if (el.tagName === 'VIDEO') { el.src = value; el.load(); } else if (item.property === 'src') { el.src = value; } else if (item.property === 'backgroundImage') { el.style.backgroundImage = value ? `url('${value}')` : ''; } else if (item.property === 'placeholder') { el.placeholder = value; } else { el.textContent = value; } } return true; } return false; } })(); // --- Audio Management --- const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); function initBGM() { const el = document.getElementById('music-bgm'); if (!el?.src) return; el.loop = true; el.volume = 0.5; const playBgm = () => { if(audioCtx.state === 'suspended') audioCtx.resume(); el.play().catch(() => { document.addEventListener('touchstart', playBgm, { once: true }); document.addEventListener('click', playBgm, { once: true }); }); }; playBgm(); } function playSound(id, type = 'synth') { if(audioCtx.state === 'suspended') audioCtx.resume(); const el = document.getElementById(`sfx-${id}`); if (el && el.src && el.src !== window.location.href) { el.currentTime = 0; el.play().catch(()=>{}); return; } // Procedural Fallbacks const osc = audioCtx.createOscillator(); const gain = audioCtx.createGain(); osc.connect(gain); gain.connect(audioCtx.destination); const now = audioCtx.currentTime; if (id === 'absorb') { osc.type = 'sine'; osc.frequency.setValueAtTime(100, now); osc.frequency.exponentialRampToValueAtTime(800, now + 0.1); osc.frequency.exponentialRampToValueAtTime(50, now + 0.3); gain.gain.setValueAtTime(0, now); gain.gain.linearRampToValueAtTime(0.5, now + 0.05); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.3); osc.start(now); osc.stop(now + 0.3); } else if (id === 'attack') { osc.type = 'square'; osc.frequency.setValueAtTime(150, now); osc.frequency.exponentialRampToValueAtTime(40, now + 0.1); gain.gain.setValueAtTime(0.6, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.2); osc.start(now); osc.stop(now + 0.2); } else if (id === 'eject') { osc.type = 'sawtooth'; osc.frequency.setValueAtTime(50, now); osc.frequency.linearRampToValueAtTime(20, now + 0.5); gain.gain.setValueAtTime(0.8, now); gain.gain.exponentialRampToValueAtTime(0.01, now + 0.5); // Add noise const noiseSize = audioCtx.sampleRate * 0.5; const noiseBuffer = audioCtx.createBuffer(1, noiseSize, audioCtx.sampleRate); const output = noiseBuffer.getChannelData(0); for (let i = 0; i < noiseSize; i++) output[i] = Math.random() * 2 - 1; const noise = audioCtx.createBufferSource(); noise.buffer = noiseBuffer; const noiseFilter = audioCtx.createBiquadFilter(); noiseFilter.type = 'lowpass'; noiseFilter.frequency.value = 1000; const noiseGain = audioCtx.createGain(); noiseGain.gain.setValueAtTime(0.5, now); noiseGain.gain.exponentialRampToValueAtTime(0.01, now + 0.5); noise.connect(noiseFilter); noiseFilter.connect(noiseGain); noiseGain.connect(audioCtx.destination); noise.start(now); osc.start(now); osc.stop(now + 0.5); } } // --- Game Engine --- const canvas = document.getElementById('game-canvas'); const ctx = canvas.getContext('2d', { alpha: false }); // Optimize let width, height; // DOM Elements const elements = { healthFill: document.getElementById('health-fill'), hostBar: document.getElementById('host-bar'), hostFill: document.getElementById('host-fill'), scoreDisplay: document.getElementById('score-display'), stateLabel: document.getElementById('state-label'), btnAttack: document.getElementById('btn-attack'), btnEject: document.getElementById('btn-eject'), joystickZone: document.getElementById('joystick-zone'), joystickBase: document.getElementById('joystick-base'), joystickStick: document.getElementById('joystick-stick'), tutorial: document.getElementById('tutorial'), gameOver: document.getElementById('game-over'), finalScore: document.getElementById('final-score'), btnRestart: document.getElementById('btn-restart') }; // Game State const GAME_STATE = { MENU: 0, PLAYING: 1, GAMEOVER: 2 }; let gameState = GAME_STATE.PLAYING; let score = 0; let lastTime = 0; let camera = { x: 0, y: 0 }; let screenShake = 0; let enemySpawnTimer = 0; let lastMoveTime = 0; // For tutorial fade // Entities let player; let entities = []; let particles = []; let loadedImages = {}; // Input const input = { joystick: { active: false, id: null, originX: 0, originY: 0, x: 0, y: 0, angle: 0, mag: 0 }, attack: false, eject: false }; // Host Types const HOST_TYPES = { SYMBIOTE: { id: 0, name: 'SYMBIOTE', maxHp: 100, speedMult: 1.5, radius: 20, color: '#1a1a2e', attackRange: 60, attackCooldown: 300, drainRate: 0, attackName: 'WHIP' }, CIVILIAN: { id: 1, name: 'CIVILIAN', maxHp: 50, speedMult: 0.8, radius: 25, color: '#f5cd79', attackRange: 40, attackCooldown: 500, drainRate: 2, attackName: 'PUNCH' }, BRUTE: { id: 2, name: 'BRUTE', maxHp: 200, speedMult: 0.6, radius: 35, color: '#e15f41', attackRange: 50, attackCooldown: 800, drainRate: 5, attackName: 'SMASH' }, SOLDIER: { id: 3, name: 'SOLDIER', maxHp: 100, speedMult: 0.9, radius: 25, color: '#546de5', attackRange: 200, attackCooldown: 400, drainRate: 3, attackName: 'SHOOT' } }; class Player { constructor(x, y) { this.x = x; this.y = y; this.vx = 0; this.vy = 0; this.type = HOST_TYPES.SYMBIOTE; this.hp = this.type.maxHp; this.hostHp = 0; // Separate HP for host this.maxHostHp = 0; this.lastAttack = 0; this.facing = 0; this.trail = []; } setHost(hostType) { if (hostType === HOST_TYPES.SYMBIOTE) { this.type = hostType; this.hostHp = 0; this.maxHostHp = 0; elements.btnEject.style.display = 'none'; elements.hostBar.style.display = 'none'; } else { this.type = hostType; this.maxHostHp = hostType.maxHp; this.hostHp = this.maxHostHp; elements.btnEject.style.display = 'flex'; elements.hostBar.style.display = 'block'; } elements.stateLabel.textContent = hostType.name; elements.btnAttack.textContent = hostType.attackName; updateHUD(); } update(dt) { if (gameState !== GAME_STATE.PLAYING) return; // Movement const baseSpeed = window.appState.config.playerSpeed * 60; // scale for dt if (input.joystick.active) { this.vx = Math.cos(input.joystick.angle) * input.joystick.mag * baseSpeed * this.type.speedMult; this.vy = Math.sin(input.joystick.angle) * input.joystick.mag * baseSpeed * this.type.speedMult; this.facing = input.joystick.angle; if (Date.now() - lastMoveTime > 1000) { elements.tutorial.classList.add('hidden'); } } else { this.vx *= 0.8; // friction this.vy *= 0.8; } this.x += this.vx * (dt / 1000); this.y += this.vy * (dt / 1000); // Constrain to arena const arenaSize = 2000; this.x = Math.max(-arenaSize/2, Math.min(arenaSize/2, this.x)); this.y = Math.max(-arenaSize/2, Math.min(arenaSize/2, this.y)); // Trail for symbiote if (this.type === HOST_TYPES.SYMBIOTE && (Math.abs(this.vx)>10 || Math.abs(this.vy)>10)) { this.trail.push({x: this.x, y: this.y, life: 1}); if (this.trail.length > 15) this.trail.shift(); } else { this.trail.shift(); } this.trail.forEach(t => t.life -= dt/200); // Host Drain if (this.type !== HOST_TYPES.SYMBIOTE) { this.hostHp -= this.type.drainRate * (dt / 1000); if (this.hostHp <= 0) { this.eject(); } updateHUD(); } // Actions if (input.attack && Date.now() - this.lastAttack > this.type.attackCooldown) { this.attack(); input.attack = false; // Require tap } if (input.eject && this.type !== HOST_TYPES.SYMBIOTE) { this.eject(); input.eject = false; } } attack() { this.lastAttack = Date.now(); playSound('attack'); const attackX = this.x + Math.cos(this.facing) * this.type.radius; const attackY = this.y + Math.sin(this.facing) * this.type.radius; // Visual fx spawnParticles(attackX, attackY, 5, this.type === HOST_TYPES.SYMBIOTE ? '#1a1a2e' : '#fff', 3); // Logic based on type if (this.type === HOST_TYPES.SOLDIER) { // Projectile entities.push(new Projectile(attackX, attackY, this.facing, 600, 20, true)); } else { // Melee area let hit = false; for (let i = entities.length - 1; i >= 0; i--) { const ent = entities[i]; if (ent instanceof Enemy) { const dist = Math.hypot(ent.x - this.x, ent.y - this.y); // Cone check const angleToEnt = Math.atan2(ent.y - this.y, ent.x - this.x); let angleDiff = angleToEnt - this.facing; while (angleDiff <= -Math.PI) angleDiff += Math.PI * 2; while (angleDiff > Math.PI) angleDiff -= Math.PI * 2; if (dist < this.type.attackRange + ent.radius && Math.abs(angleDiff) < Math.PI/4) { ent.hp -= (this.type === HOST_TYPES.BRUTE ? 50 : 20); hit = true; spawnParticles(ent.x, ent.y, 10, '#e94560'); screenShake = this.type === HOST_TYPES.BRUTE ? 10 : 3; if (ent.hp <= 0) { score += 100; elements.scoreDisplay.textContent = score; ent.toRemove = true; } else { // Knockback ent.vx = Math.cos(this.facing) * 300; ent.vy = Math.sin(this.facing) * 300; } } } } } } eject() { playSound('eject'); spawnParticles(this.x, this.y, 30, '#e94560', 5); screenShake = 15; // AOE damage on eject for (let i = entities.length - 1; i >= 0; i--) { const ent = entities[i]; if (ent instanceof Enemy) { const dist = Math.hypot(ent.x - this.x, ent.y - this.y); if (dist < 150) { ent.hp -= 100; if (ent.hp <= 0) { score += 50; ent.toRemove = true; } } } } this.setHost(HOST_TYPES.SYMBIOTE); } takeDamage(amount) { if (this.type !== HOST_TYPES.SYMBIOTE && this.hostHp > 0) { this.hostHp -= amount; if(this.hostHp <= 0) this.eject(); } else { this.hp -= amount; if (this.hp <= 0) { gameOver(); } } updateHUD(); screenShake = 5; playSound('attack'); } draw(ctx) { ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(this.facing); if (this.type === HOST_TYPES.SYMBIOTE) { // Draw trail ctx.rotate(-this.facing); // Unrotate for trail world coords ctx.translate(-this.x, -this.y); ctx.beginPath(); for (let i = 0; i < this.trail.length; i++) { const t = this.trail[i]; if(t.life<=0) continue; ctx.lineTo(t.x, t.y); ctx.lineWidth = this.type.radius * 2 * t.life; ctx.strokeStyle = `rgba(26, 26, 46, ${t.life})`; ctx.stroke(); ctx.beginPath(); ctx.moveTo(t.x, t.y); } ctx.translate(this.x, this.y); ctx.rotate(this.facing); // Main body ctx.fillStyle = this.type.color; ctx.beginPath(); ctx.arc(0, 0, this.type.radius, 0, Math.PI * 2); ctx.fill(); // Pulsing effect ctx.fillStyle = 'rgba(233, 69, 96, 0.5)'; ctx.beginPath(); ctx.arc(0, 0, this.type.radius * (0.8 + 0.2*Math.sin(Date.now()/100)), 0, Math.PI * 2); ctx.fill(); } else { // Draw Host ctx.fillStyle = this.type.color; ctx.beginPath(); ctx.arc(0, 0, this.type.radius, 0, Math.PI * 2); ctx.fill(); // Direction indicator / shoulders ctx.fillStyle = '#111'; ctx.fillRect(this.type.radius - 5, -15, 10, 30); // Symbiote corruption visual const corruptRatio = 1 - (this.hostHp / this.maxHostHp); ctx.fillStyle = `rgba(26, 26, 46, ${corruptRatio * 0.8})`; ctx.beginPath(); ctx.arc(0, 0, this.type.radius + 2, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); } } class Enemy { constructor(x, y, isHost = false, hostType = null) { this.x = x; this.y = y; this.vx = 0; this.vy = 0; this.isHost = isHost; // true = absorbable civilian/brute this.toRemove = false; if (isHost) { this.type = hostType || (Math.random() > 0.8 ? HOST_TYPES.BRUTE : HOST_TYPES.CIVILIAN); this.hp = this.type.maxHp; } else { this.type = HOST_TYPES.SOLDIER; this.hp = 30; } this.radius = this.type.radius; this.speed = this.type.speedMult * 150; this.lastAttack = 0; this.facing = 0; } update(dt) { const distToPlayer = Math.hypot(player.x - this.x, player.y - this.y); // Behavior if (this.isHost) { // Hosts wander or flee if (distToPlayer < 200 && player.type === HOST_TYPES.SYMBIOTE) { // Flee symbiote this.facing = Math.atan2(this.y - player.y, this.x - player.x); this.vx = Math.cos(this.facing) * this.speed; this.vy = Math.sin(this.facing) * this.speed; } else { // Wander if (Math.random() < 0.02) this.facing = Math.random() * Math.PI * 2; this.vx = Math.cos(this.facing) * this.speed * 0.3; this.vy = Math.sin(this.facing) * this.speed * 0.3; } } else { // Enemies attack if (distToPlayer < 600) { this.facing = Math.atan2(player.y - this.y, player.x - this.x); if (distToPlayer > 150) { this.vx = Math.cos(this.facing) * this.speed; this.vy = Math.sin(this.facing) * this.speed; } else { this.vx *= 0.5; this.vy *= 0.5; } // Shoot if (distToPlayer < 300 && Date.now() - this.lastAttack > 1500) { this.lastAttack = Date.now(); entities.push(new Projectile(this.x, this.y, this.facing, 400, 10, false)); playSound('attack'); // temp shoot sound } } else { this.vx *= 0.9; this.vy *= 0.9; } } this.x += this.vx * (dt/1000); this.y += this.vy * (dt/1000); // Constrain const arenaSize = 2000; this.x = Math.max(-arenaSize/2, Math.min(arenaSize/2, this.x)); this.y = Math.max(-arenaSize/2, Math.min(arenaSize/2, this.y)); // Absorb Check if (this.isHost && player.type === HOST_TYPES.SYMBIOTE) { if (distToPlayer < player.type.radius + this.radius) { player.setHost(this.type); playSound('absorb'); spawnParticles(this.x, this.y, 20, '#1a1a2e', 4); return true; // Mark for deletion } } // Melee hit player if (!this.isHost && distToPlayer < player.type.radius + this.radius && Date.now() - this.lastAttack > 1000) { player.takeDamage(10); this.lastAttack = Date.now(); } return false; } draw(ctx) { ctx.save(); ctx.translate(this.x, this.y); ctx.rotate(this.facing); ctx.fillStyle = this.type.color; ctx.beginPath(); ctx.arc(0, 0, this.radius, 0, Math.PI * 2); ctx.fill(); // Details ctx.fillStyle = '#111'; if (this.type === HOST_TYPES.SOLDIER) { // Gun ctx.fillRect(10, -5, 25, 10); } else if (this.isHost) { // Shoulders ctx.fillRect(this.radius - 5, -15, 10, 30); } ctx.restore(); } } class Projectile { constructor(x, y, angle, speed, damage, isPlayer) { this.x = x; this.y = y; this.vx = Math.cos(angle) * speed; this.vy = Math.sin(angle) * speed; this.damage = damage; this.isPlayer = isPlayer; this.life = 2000; // ms this.radius = 5; this.toRemove = false; } update(dt) { this.x += this.vx * (dt/1000); this.y += this.vy * (dt/1000); this.life -= dt; // Collision if (this.isPlayer) { for (let i = entities.length - 1; i >= 0; i--) { const ent = entities[i]; if (ent instanceof Enemy && !ent.isHost) { if (Math.hypot(ent.x - this.x, ent.y - this.y) < ent.radius + this.radius) { ent.hp -= this.damage; spawnParticles(this.x, this.y, 5, '#fff'); if (ent.hp <= 0) { score += 50; elements.scoreDisplay.textContent = score; ent.toRemove = true; } return true; } } } } else { if (Math.hypot(player.x - this.x, player.y - this.y) < player.type.radius + this.radius) { player.takeDamage(this.damage); spawnParticles(this.x, this.y, 5, '#e94560'); return true; } } return this.life <= 0; } draw(ctx) { ctx.fillStyle = this.isPlayer ? '#f1c40f' : '#e74c3c'; ctx.beginPath(); ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2); ctx.fill(); } } class Particle { constructor(x, y, color, sizeMultiplier = 1) { this.x = x; this.y = y; const angle = Math.random() * Math.PI * 2; const speed = Math.random() * 200 + 50; this.vx = Math.cos(angle) * speed; this.vy = Math.sin(angle) * speed; this.color = color; this.life = 1.0; this.decay = Math.random() * 0.02 + 0.01; this.size = (Math.random() * 4 + 2) * sizeMultiplier; } update(dt) { this.x += this.vx * (dt/1000); this.y += this.vy * (dt/1000); this.vx *= 0.95; this.vy *= 0.95; this.life -= this.decay * (dt/16); return this.life <= 0; } draw(ctx) { ctx.globalAlpha = this.life; ctx.fillStyle = this.color; ctx.beginPath(); ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2); ctx.fill(); ctx.globalAlpha = 1.0; } } function spawnParticles(x, y, count, color, size = 1) { for(let i=0; i 30) return; // Cap const angle = Math.random() * Math.PI * 2; const dist = Math.random() * 500 + 400; // Spawn outside view const x = player.x + Math.cos(angle) * dist; const y = player.y + Math.sin(angle) * dist; const isHost = forceHost || Math.random() > 0.7; entities.push(new Enemy(x, y, isHost)); } // --- Main Loop --- function gameLoop(timestamp) { if (!lastTime) lastTime = timestamp; let dt = timestamp - lastTime; lastTime = timestamp; // Cap dt to prevent huge jumps on tab switch if (dt > 100) dt = 16; resize(); // Check resize // Update if (gameState === GAME_STATE.PLAYING) { player.update(dt); for (let i = entities.length - 1; i >= 0; i--) { const ent = entities[i]; if (!ent) continue; // Safety guard for undefined access if (ent.toRemove || ent.update(dt)) { entities.splice(i, 1); } } for (let i = particles.length - 1; i >= 0; i--) { if (particles[i].update(dt)) { particles.splice(i, 1); } } // Spawning enemySpawnTimer -= dt; if (enemySpawnTimer <= 0) { spawnEntity(); enemySpawnTimer = window.appState.config.spawnRate; } // Camera follow camera.x += (player.x - camera.x) * 0.1; camera.y += (player.y - camera.y) * 0.1; } if (screenShake > 0) { screenShake *= 0.9; if (screenShake < 0.5) screenShake = 0; } // Render ctx.fillStyle = '#0f0f1a'; // Base bg ctx.fillRect(0, 0, width, height); ctx.save(); ctx.translate(width/2, height/2); if (screenShake > 0) { ctx.translate((Math.random()-0.5)*screenShake, (Math.random()-0.5)*screenShake); } ctx.translate(-camera.x, -camera.y); // Draw Grid/Floor ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 2; const gridSize = 100; const startX = Math.floor((camera.x - width/2) / gridSize) * gridSize; const endX = startX + width + gridSize; const startY = Math.floor((camera.y - height/2) / gridSize) * gridSize; const endY = startY + height + gridSize; ctx.beginPath(); for (let x = startX; x <= endX; x += gridSize) { ctx.moveTo(x, startY); ctx.lineTo(x, endY); } for (let y = startY; y <= endY; y += gridSize) { ctx.moveTo(startX, y); ctx.lineTo(endX, y); } ctx.stroke(); // Arena Bounds ctx.strokeStyle = '#e94560'; ctx.lineWidth = 5; ctx.strokeRect(-1000, -1000, 2000, 2000); // Draw Entities // Sort by Y for simple pseudo-depth const renderList = [player, ...entities].sort((a,b) => a.y - b.y); renderList.forEach(e => e.draw(ctx)); particles.forEach(p => p.draw(ctx)); ctx.restore(); requestAnimationFrame(gameLoop); } function resize() { if (canvas.width !== window.innerWidth || canvas.height !== window.innerHeight) { width = window.innerWidth; height = window.innerHeight; canvas.width = width; canvas.height = height; } } // --- Input Handling --- function handleTouch(e) { if (gameState !== GAME_STATE.PLAYING) return; const touch = e.changedTouches ? e.changedTouches[0] : e; // Joystick Logic if (e.type === 'pointerdown' || e.type === 'touchstart') { const target = e.target.closest('.action-btn, #btn-restart'); if (target) return; // Handled by button listeners if (!input.joystick.active && touch.clientX < width / 2) { input.joystick.active = true; input.joystick.id = touch.pointerId || touch.identifier; input.joystick.originX = touch.clientX; input.joystick.originY = touch.clientY; elements.joystickBase.style.display = 'block'; elements.joystickBase.style.left = touch.clientX + 'px'; elements.joystickBase.style.top = touch.clientY + 'px'; elements.joystickStick.style.transform = 'translate(-50%, -50%)'; lastMoveTime = Date.now(); } } if ((e.type === 'pointermove' || e.type === 'touchmove') && input.joystick.active) { // Find matching touch let activeTouch = null; if (e.changedTouches) { for(let i=0; i { document.getElementById('ui-layer').addEventListener(evt, handleTouch, {passive: false}); }); elements.btnAttack.addEventListener('pointerdown', (e) => { e.stopPropagation(); input.attack = true; }); elements.btnAttack.addEventListener('pointerup', () => { input.attack = false; }); elements.btnAttack.addEventListener('pointercancel', () => { input.attack = false; }); elements.btnEject.addEventListener('pointerdown', (e) => { e.stopPropagation(); input.eject = true; }); elements.btnRestart.addEventListener('pointerdown', (e) => { e.stopPropagation(); restartGame(); }); // Prevent context menu document.addEventListener('contextmenu', e => e.preventDefault()); // Initialization function initApp() { applyAllEditableValues(); initBGM(); resize(); restartGame(); requestAnimationFrame(gameLoop); } document.addEventListener('DOMContentLoaded', initApp);