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);