| 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; |
| 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); |
|
|
| |
| const scale = Math.min(app.screen.width / VIRTUAL_WIDTH, app.screen.height / VIRTUAL_HEIGHT); |
| app.stage.scale.set(scale); |
|
|
| |
| 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'); |
| } |
| |
| |
| createTextures(); |
| |
| |
| 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); |
| } |
| |
| |
| 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); |
|
|
| |
| const floor = new PIXI.TilingSprite(textures.ground, WORLD_SIZE, WORLD_SIZE); |
| floor.anchor.set(0.5); |
| floor.tileScale.set(32 / 2048); |
| world.addChild(floor); |
|
|
| player = new PIXI.Sprite(textures.player); |
| player.anchor.set(0.5, 1); |
| player.position.set(0, 0); |
| |
| 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; |
| 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); |
|
|
| |
| menuContainer = new PIXI.Container(); |
| menuContainer.visible = false; |
| ui.addChild(menuContainer); |
| |
| |
| const menuBg = new PIXI.Graphics(); |
| menuBg.rect(0, 0, VIRTUAL_WIDTH, VIRTUAL_HEIGHT).fill({ color: 0x000000, alpha: 0.8 }); |
| menuContainer.addChild(menuBg); |
| |
| |
| 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); |
|
|
| |
| menuContainer.addChild(optionsText); |
|
|
| |
| 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); |
| |
| |
| const originalUpdate = update; |
| update = (ticker) => { |
| if (isPaused) { |
| pauseText.visible = !menuContainer.visible; |
| } else { |
| pauseText.visible = false; |
| } |
| originalUpdate(ticker); |
| }; |
|
|
| |
| 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(); |
| |
| |
| spawnTimer += dt; |
| const spawnInterval = Math.max(15, 50 - wave * 3); |
| if (spawnTimer >= spawnInterval) { |
| spawnTimer = 0; |
| |
| const targetCount = Math.max(3, 5 + wave); |
| const toSpawn = Math.max(1, targetCount - enemies.length); |
| for (let i = 0; i < toSpawn; i++) { |
| spawnEnemy(); |
| } |
| } |
|
|
| |
| if (waveKills >= 10 + wave * 5) { |
| wave++; |
| waveKills = 0; |
| showWaveText(); |
| } |
| } |
|
|
| |
| 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; |
| |
| |
| if (dx > 0) player.facing = 1; |
| else if (dx < 0) player.facing = -1; |
| } else { |
| |
| 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; |
| } |
| } |
| |
| |
| 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() { |
| |
| 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); |
| } |
|
|
| |
| 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); |
| |
| |
| 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; |
| |
| |
| 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; |
| |
| |
| 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; |
| } |
|
|
| |
| 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++; |
| } |
| |
| |
| 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; |
| 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++; |
| |
| |
| xpToNext = Math.floor(100 + (level * 50) + (Math.log(level) * 50)); |
| |
| menuContainer.visible = true; |
|
|
| |
| 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; } } |
| ]; |
|
|
| |
| 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); |
| |
| 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); |
| |
| 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(); |
|
|