import * as PIXI from 'https://cdn.jsdelivr.net/npm/pixi.js@8/dist/pixi.min.mjs'; const VIRTUAL_WIDTH = 320; const VIRTUAL_HEIGHT = 320; const WORLD_SIZE = 500; const SPAWN_RADIUS = 200; const PLAYER_SPEED = 2; const ENEMY_BASE_SPEED = 0.8; const PROJECTILE_SPEED = 4; const FIRE_RATE = 35; let app, world, player; let enemies = [], projectiles = [], gems = [], particles = []; let keys = {}; let level = 1, xp = 0, xpToNext = 100, kills = 0, wave = 1; let isPaused = false, gameOver = false; let hudText, hpBarGraphics, levelText, waveText, menuContainer; let panelX, panelY; // Define globally let ticker = 0; let spawnTimer = 0; let waveKills = 0; let menuOptions = null; const textures = {}; async function init() { app = new PIXI.Application(); await app.init({ width: VIRTUAL_WIDTH, height: VIRTUAL_HEIGHT, backgroundColor: 0x111111, antialias: false, resolution: window.devicePixelRatio || 1, autoDensity: true, resizeTo: document.getElementById('game-container') }); document.getElementById('game-container').appendChild(app.canvas); // Scale stage const scale = Math.min(app.screen.width / VIRTUAL_WIDTH, app.screen.height / VIRTUAL_HEIGHT); app.stage.scale.set(scale); // Load assets safely try { textures.player = await PIXI.Assets.load('assets/player.png'); textures.player.source.scaleMode = 'nearest'; } catch (e) { console.warn('Failed to load player.png, using procedural fallback'); } try { textures.ground = await PIXI.Assets.load('assets/ground_tile.png'); textures.ground.source.scaleMode = 'nearest'; } catch (e) { console.warn('Failed to load ground_tile.png, using procedural fallback'); const canvas = document.createElement('canvas'); canvas.width = 32; canvas.height = 32; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#111'; ctx.fillRect(0,0,32,32); textures.ground = PIXI.Texture.from(canvas); } try { textures.zombie = await PIXI.Assets.load('assets/zombie.png'); textures.zombie.source.scaleMode = 'nearest'; } catch (e) { console.warn('Failed to load zombie.png'); } // Always call createTextures to ensure textures exist if fallback needed createTextures(); // Ensure player texture is set if assets failed if (!textures.player) { const canvas = document.createElement('canvas'); canvas.width = 16; canvas.height = 16; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#3498db'; ctx.fillRect(0,0,16,16); textures.player = PIXI.Texture.from(canvas); } // Ensure enemy texture is set if zombie.png failed if (!textures.zombie) { textures.zombie = textures.enemy; } setupGame(); window.addEventListener('keydown', (e) => { keys[e.code] = true; if (e.code === 'KeyP') isPaused = !isPaused; handleKeyPress(e); }); window.addEventListener('keyup', (e) => { keys[e.code] = false; }); app.ticker.add((ticker) => wrappedUpdate(ticker)); } function createTextures() { const createFromCanvas = (name, draw, w = 16, h = 16) => { const canvas = document.createElement('canvas'); canvas.width = w; canvas.height = h; draw(canvas.getContext('2d'), w, h); const tex = PIXI.Texture.from(canvas); tex.source.scaleMode = 'nearest'; textures[name] = tex; }; createFromCanvas('enemy', (ctx, w, h) => { ctx.fillStyle = '#c0392b'; ctx.fillRect(2, 2, 12, 12); ctx.fillStyle = '#000'; ctx.fillRect(4, 4, 2, 2); ctx.fillRect(10, 4, 2, 2); ctx.fillRect(5, 10, 6, 2); }); createFromCanvas('projectile', (ctx, w, h) => { ctx.fillStyle = '#f1c40f'; ctx.fillRect(6, 6, 4, 4); }, 16, 16); createFromCanvas('gem', (ctx, w, h) => { ctx.fillStyle = '#2ecc71'; ctx.beginPath(); ctx.moveTo(8, 2); ctx.lineTo(14, 8); ctx.lineTo(8, 14); ctx.lineTo(2, 8); ctx.closePath(); ctx.fill(); }); createFromCanvas('particle', (ctx, w, h) => { ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, 2, 2); }, 2, 2); } function setupGame() { world = new PIXI.Container(); app.stage.addChild(world); // Replace procedural floor with TilingSprite const floor = new PIXI.TilingSprite(textures.ground, WORLD_SIZE, WORLD_SIZE); floor.anchor.set(0.5); floor.tileScale.set(32 / 2048); // Scale 2048px original down to 32px visual world.addChild(floor); player = new PIXI.Sprite(textures.player); player.anchor.set(0.5, 1); // Anchor at bottom-center player.position.set(0, 0); // World coordinates start at 0,0 // Scale down to ~32px (1500px width) player.scale.set(32 / textures.player.width); player.hp = 100; player.maxHp = 100; player.damage = 25; player.fireDelay = 0; player.range = 150; player.facing = 1; // Initialize facing world.addChild(player); const ui = new PIXI.Container(); app.stage.addChild(ui); hudText = new PIXI.Text({ text: 'LVL: 1 XP: 0/100', style: { fontFamily: 'Courier New', fontSize: 10, fill: 0xffffff, fontWeight: 'bold' } }); hudText.position.set(5, 5); ui.addChild(hudText); waveText = new PIXI.Text({ text: 'WAVE 1', style: { fontFamily: 'Courier New', fontSize: 10, fill: 0xf1c40f } }); waveText.position.set(5, 18); ui.addChild(waveText); hpBarGraphics = new PIXI.Graphics(); ui.addChild(hpBarGraphics); levelText = new PIXI.Text({ text: '', style: { fontFamily: 'Courier New', fontSize: 12, fill: 0xffffff, align: 'center' } }); levelText.anchor.set(0.5); levelText.position.set(VIRTUAL_WIDTH/2, VIRTUAL_HEIGHT/2); levelText.visible = false; ui.addChild(levelText); // Add styled menu container menuContainer = new PIXI.Container(); menuContainer.visible = false; ui.addChild(menuContainer); // Background overlay const menuBg = new PIXI.Graphics(); menuBg.rect(0, 0, VIRTUAL_WIDTH, VIRTUAL_HEIGHT).fill({ color: 0x000000, alpha: 0.8 }); menuContainer.addChild(menuBg); // Centered Panel const panelW = 220; const panelH = 160; panelX = (VIRTUAL_WIDTH - panelW) / 2; panelY = (VIRTUAL_HEIGHT - panelH) / 2; const menuPanel = new PIXI.Graphics(); menuPanel.rect(panelX, panelY, panelW, panelH).fill(0x222222).stroke({ width: 2, color: 0x555555 }); menuContainer.addChild(menuPanel); const title = new PIXI.Text({ text: 'LEVEL UP!', style: { fontFamily: 'Courier New', fontSize: 18, fill: 0xf1c40f, fontWeight: 'bold' } }); title.anchor.set(0.5); title.position.set(VIRTUAL_WIDTH/2, panelY + 30); menuContainer.addChild(title); const optionsText = new PIXI.Text({ text: '1: HP +25\n2: DMG +15\n3: Fire Rate +\n4: Full Heal', style: { fontFamily: 'Courier New', fontSize: 12, fill: 0xffffff, leading: 6 } }); optionsText.position.set(panelX + 30, panelY + 65); menuContainer.addChild(optionsText); // ... (previous setup code) menuContainer.addChild(optionsText); // Pause text const pauseText = new PIXI.Text({ text: 'PAUSED', style: { fontFamily: 'Courier New', fontSize: 24, fill: 0xffffff, fontWeight: 'bold' } }); pauseText.anchor.set(0.5); pauseText.position.set(VIRTUAL_WIDTH/2, VIRTUAL_HEIGHT/2); pauseText.visible = false; ui.addChild(pauseText); // Track pause state for UI visibility const originalUpdate = update; update = (ticker) => { if (isPaused) { pauseText.visible = !menuContainer.visible; } else { pauseText.visible = false; } originalUpdate(ticker); }; // Spawn initial enemies for (let i = 0; i < 5; i++) { spawnEnemy(); } } function update(ticker) { if (isPaused || gameOver) return; const dt = ticker.deltaTime; ticker += dt; handleMovement(dt); updateCamera(); handleCombat(dt); updateEnemies(dt); updateProjectiles(dt); updateGems(); updateParticles(dt); updateHUD(); // Spawn enemies continuously spawnTimer += dt; const spawnInterval = Math.max(15, 50 - wave * 3); if (spawnTimer >= spawnInterval) { spawnTimer = 0; // Always ensure at least 2 enemies exist const targetCount = Math.max(3, 5 + wave); const toSpawn = Math.max(1, targetCount - enemies.length); for (let i = 0; i < toSpawn; i++) { spawnEnemy(); } } // Wave progression if (waveKills >= 10 + wave * 5) { wave++; waveKills = 0; showWaveText(); } } // Wrapper for UI visibility const wrappedUpdate = (ticker) => { if (isPaused) { if (typeof pauseText !== 'undefined') pauseText.visible = !menuContainer.visible; } else { if (typeof pauseText !== 'undefined') pauseText.visible = false; } update(ticker); }; function showWaveText() { waveText.text = `WAVE ${wave}`; } function handleMovement(dt) { let dx = 0, dy = 0; if (keys['KeyW'] || keys['ArrowUp']) dy -= 1; if (keys['KeyS'] || keys['ArrowDown']) dy += 1; if (keys['KeyA'] || keys['ArrowLeft']) dx -= 1; if (keys['KeyD'] || keys['ArrowRight']) dx += 1; if (dx !== 0 || dy !== 0) { const len = Math.sqrt(dx * dx + dy * dy); const speed = PLAYER_SPEED + (player.speedBoost || 0); player.x += (dx / len) * speed * dt; player.y += (dy / len) * speed * dt; // Direction facing if (dx > 0) player.facing = 1; else if (dx < 0) player.facing = -1; } else { // Face nearest enemy if idle let nearest = null, minDist = player.range * 2; for (const e of enemies) { const d = dist(player, e); if (d < minDist) { minDist = d; nearest = e; } } if (nearest) { player.facing = (nearest.x > player.x) ? 1 : -1; } } // Breathing effect (sine wave oscillation) const breath = 1 + Math.sin(Date.now() * 0.005) * 0.03; const baseScale = 32 / textures.player.width; player.scale.set((player.facing || 1) * baseScale, baseScale * breath); const half = WORLD_SIZE / 2 - 16; player.x = Math.max(-half, Math.min(half, player.x)); player.y = Math.max(-half, Math.min(half, player.y)); } function updateCamera() { // Keep player centered on screen world.x = VIRTUAL_WIDTH / 2 - player.x; world.y = VIRTUAL_HEIGHT / 2 - player.y; } function handleCombat(dt) { player.fireDelay -= dt; if (player.fireDelay <= 0) { let nearest = null, minDist = player.range; for (const e of enemies) { const d = dist(player, e); if (d < minDist) { minDist = d; nearest = e; } } if (nearest) { shoot(nearest); player.fireDelay = FIRE_RATE; } } } function shoot(target) { const p = new PIXI.Sprite(textures.projectile); p.anchor.set(0.5); p.position.set(player.x, player.y); const angle = Math.atan2(target.y - player.y, target.x - player.x); p.vx = Math.cos(angle) * PROJECTILE_SPEED; p.vy = Math.sin(angle) * PROJECTILE_SPEED; p.damage = player.damage; p.pierce = player.pierce || 0; p.scale.set(2); world.addChild(p); projectiles.push(p); } // Monster Types const ENEMY_TYPES = [ { name: 'basic', color: 0xc0392b, hp: 30, speed: 0.8, xp: 15, dmg: 0.5 }, { name: 'tank', color: 0x8e44ad, hp: 100, speed: 0.4, xp: 40, dmg: 1.5 }, { name: 'fast', color: 0xf39c12, hp: 20, speed: 1.5, xp: 25, dmg: 0.3 } ]; function spawnEnemy() { const angle = Math.random() * Math.PI * 2; const e = new PIXI.Sprite(textures.zombie); e.anchor.set(0.5); // Choose enemy type based on wave const typeIdx = wave > 3 ? Math.floor(Math.random() * ENEMY_TYPES.length) : 0; const type = ENEMY_TYPES[typeIdx]; e.position.set(player.x + Math.cos(angle) * 150, player.y + Math.sin(angle) * 150); e.hp = (type.hp + wave * 10) * (1 + wave * 0.1); e.speed = type.speed + wave * 0.05; e.xp = type.xp; e.dmg = type.dmg + wave * 0.1; e.tint = type.color; e.typeColor = type.color; e.flashTimer = 0; e.scale.set(32 / textures.zombie.width); world.addChild(e); enemies.push(e); } function updateEnemies(dt) { for (let i = enemies.length - 1; i >= 0; i--) { const e = enemies[i]; const angle = Math.atan2(player.y - e.y, player.x - e.x); e.x += Math.cos(angle) * e.speed * dt; e.y += Math.sin(angle) * e.speed * dt; // Face player: texture is facing right, so if player.x > e.x, face right (1), else left (-1) const baseScale = 32 / textures.zombie.width; e.scale.x = (player.x > e.x) ? baseScale : -baseScale; e.scale.y = baseScale; if (dist(player, e) < 10) damagePlayer(e.dmg * dt); if (e.flashTimer > 0) { e.flashTimer -= dt; e.tint = 0xff0000; } else { e.tint = e.typeColor; } } } function updateProjectiles(dt) { for (let i = projectiles.length - 1; i >= 0; i--) { const p = projectiles[i]; p.x += p.vx * dt; p.y += p.vy * dt; for (let j = enemies.length - 1; j >= 0; j--) { const e = enemies[j]; if (dist(p, e) < 12) { e.hp -= p.damage; e.flashTimer = 4; // Knockback if (player.knockback) { const angle = Math.atan2(e.y - player.y, e.x - player.x); e.x += Math.cos(angle) * player.knockback * 10; e.y += Math.sin(angle) * player.knockback * 10; } // Lifesteal if (player.lifesteal) player.hp = Math.min(player.maxHp, player.hp + p.damage * player.lifesteal); spawnParticle(p.x, p.y, 0xf1c40f); if (e.hp <= 0) { spawnGem(e.x, e.y, e.xp); world.removeChild(e); enemies.splice(j, 1); kills++; waveKills++; } // Pierce check if (p.pierce > 0) { p.pierce--; } else { world.removeChild(p); projectiles.splice(i, 1); break; } } } if (projectiles[i] && dist(p, player) > 400) { world.removeChild(p); projectiles.splice(i, 1); } } } function spawnGem(x, y, xpValue) { const gem = new PIXI.Sprite(textures.gem); gem.anchor.set(0.5); gem.position.set(x, y); gem.scale.set(0.8); gem.xpValue = xpValue; // Store XP on gem world.addChild(gem); gems.push(gem); } function updateGems() { for (let i = gems.length - 1; i >= 0; i--) { const g = gems[i]; const d = dist(player, g); if (d < 50) { const angle = Math.atan2(player.y - g.y, player.x - g.x); g.x += Math.cos(angle) * 5; g.y += Math.sin(angle) * 5; } if (d < 12) { world.removeChild(g); gems.splice(i, 1); gainXp(g.xpValue); } } } function gainXp(amount) { xp += amount; if (xp >= xpToNext) levelUp(); } function levelUp() { isPaused = true; xp -= xpToNext; level++; // Smoother scaling: linear + logarithmic correction xpToNext = Math.floor(100 + (level * 50) + (Math.log(level) * 50)); menuContainer.visible = true; // Define all available upgrades const allOptions = [ { name: '+25 HP', action: () => { player.maxHp += 25; player.hp += 25; } }, { name: '+15 DMG', action: () => { player.damage += 15; } }, { name: 'Fire Rate', action: () => { player.fireDelay = Math.max(10, player.fireDelay - 5); } }, { name: 'Full Heal', action: () => { player.hp = player.maxHp; } }, { name: '+Move Spd', action: () => { player.speedBoost = (player.speedBoost || 0) + 0.5; } }, { name: 'Knockback', action: () => { player.knockback = (player.knockback || 0) + 2; } }, { name: 'Pierce', action: () => { player.pierce = (player.pierce || 0) + 1; } }, { name: 'Range', action: () => { player.range += 30; } }, { name: 'Life Steal', action: () => { player.lifesteal = (player.lifesteal || 0) + 0.05; } } ]; // Select 4 random unique options const shuffled = allOptions.sort(() => 0.5 - Math.random()); const selected = shuffled.slice(0, 4); menuOptions = selected.map(o => o.action); const optionsText = new PIXI.Text({ text: selected.map((o, i) => `${i+1}: ${o.name}`).join('\n'), style: { fontFamily: 'Courier New', fontSize: 12, fill: 0xffffff, leading: 6 } }); optionsText.position.set(panelX + 30, panelY + 65); // Remove previous optionsText if it exists in menuContainer menuContainer.children.forEach(c => { if (c.text && c.text.includes(':')) menuContainer.removeChild(c); }); menuContainer.addChild(optionsText); } function handleKeyPress(e) { if (gameOver) { if (e.code === 'Enter') location.reload(); return; } if (!isPaused) return; if (menuOptions) { if (e.key === '1') menuOptions[0](); else if (e.key === '2') menuOptions[1](); else if (e.key === '3') menuOptions[2](); else if (e.key === '4') menuOptions[3](); else return; menuContainer.visible = false; menuOptions = null; isPaused = false; } } function damagePlayer(amount) { player.hp -= amount; player.tint = 0xff0000; setTimeout(() => { if (player) player.tint = 0xffffff; }, 80); if (player.hp <= 0 && !gameOver) { gameOver = true; levelText.text = `GAME OVER\n\nKILLS: ${kills}\nLVL: ${level}\n\nENTER: Restart`; levelText.visible = true; } } function updateHUD() { hudText.text = `LVL: ${level} XP: ${xp}/${xpToNext}`; hpBarGraphics.clear(); const pct = Math.max(0, player.hp / player.maxHp); hpBarGraphics.rect(5, 30, 60, 4).fill(0x333333); hpBarGraphics.rect(5, 30, 60 * pct, 4).fill(0xe74c3c); // Debug stats if (Math.random() < 0.01) console.log(`DMG: ${player.damage}, Pierce: ${player.pierce || 0}, SpdBoost: ${player.speedBoost || 0}`); } function spawnParticle(x, y, color) { for (let i = 0; i < 4; i++) { const p = new PIXI.Sprite(textures.particle); p.anchor.set(0.5); p.position.set(x, y); p.vx = (Math.random() - 0.5) * 3; p.vy = (Math.random() - 0.5) * 3; p.life = 15; p.tint = color; world.addChild(p); particles.push(p); } } function updateParticles(dt) { for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i]; p.x += p.vx * dt; p.y += p.vy * dt; p.life -= dt; p.alpha = Math.max(0, p.life / 15); if (p.life <= 0) { world.removeChild(p); particles.splice(i, 1); } } } function dist(a, b) { const dx = a.x - b.x, dy = a.y - b.y; return Math.sqrt(dx * dx + dy * dy); } init();