User prompt
Imps do 1 base damage, eyeballs 2 and monster 3.
User prompt
Instead of being a Boolean, give player a defense stat and every defense powerup adds to it and subtracts half a point from damage taken to a minimum of half of a point of damage.
Code edit (1 edits merged)
Please save this source code
User prompt
Please fix the bug: 'ReferenceError: Can't find variable: fireballStartZ' in or related to this line: 'var startZ = fireballStartZ + (Math.random() - 0.5) * 0.4; // Vary height slightly' Line Number: 186
User prompt
Update with: self.performFireballAttack = function (playerWorldX, playerWorldY) { if (!self.canShootFireball || !self.parent) { return; } console.log("Boss starting fireball attack!"); // Debug output self.canShootFireball = false; self.lastFireballTime = Date.now(); // ALWAYS trigger attack animation as warning if (!self.isAttacking) { self.performAttackAnimation(); } // Store target position when attack starts var targetX = playerWorldX; var targetY = playerWorldY; // Calculate fireball spawn position var fireballStartX = self.mapX + Math.cos(player.dir) * 0.5; var fireballStartY = self.mapY + Math.sin(player.dir) * 0.5; var fireballStartZ = 0.8; // CREATE CHARGING PARTICLE EFFECT self.createChargingEffect(fireballStartX, fireballStartY, fireballStartZ); // Delay fireball spawn to give player warning time var fireballTimer = LK.setTimeout(function () { if (!self.parent) { return; // Boss was destroyed } console.log("Boss firing fireball after delay!"); // Debug output var fireball = new FireballProjectile(); // Fire at the position where player WAS when attack started fireball.fire(fireballStartX, fireballStartY, fireballStartZ, targetX, targetY); projectiles.push(fireball); projectileLayer.addChild(fireball); }, 600); // 600ms warning time self.currentFireballTimer = fireballTimer; var resetCooldown = function() { if (self.parent) { self.canShootFireball = true; self.currentFireballTimer = null; console.log("Boss fireball cooldown reset"); // Debug output } }; LK.setTimeout(resetCooldown, self.fireballCooldown); };
User prompt
Update only as needed with: // In the Particle class update method, add this near the beginning: self.update = function () { if (self.life <= 0 || self.bounces >= PARTICLE_MAX_BOUNCES) { self.visible = false; return false; // Indicate inactive } // Special handling for charging particles - no gravity if (self.isChargingParticle) { // Store pre-move position for collision response var prevWorldX = self.worldX; var prevWorldY = self.worldY; // Apply world velocities (no gravity for charging particles) self.worldX += self.vx; self.worldY += self.vy; self.worldZ += self.vz; // Skip wall collision for charging particles (they have immunity) // ... rest of positioning code is the same } else { // Regular particle physics (existing code) // Store pre-move position for collision response var prevWorldX = self.worldX; var prevWorldY = self.worldY; // Apply world velocities self.worldX += self.vx; self.worldY += self.vy; self.worldZ += self.vz; // Apply gravity to vertical velocity self.vz -= PARTICLE_WORLD_GRAVITY_EFFECT; // ... rest of existing particle physics code } // ... rest of the positioning and rendering code stays the same }; ``` **In the Particle init method, make sure to reset the charging flag:** ```javascript // In the Particle init method, add this line: self.init = function (wX, wY, wZ, velX, velY, velZ, particleLife, tintColor) { self.worldX = wX; self.worldY = wY; self.worldZ = wZ; self.vx = velX; self.vy = velY; self.vz = velZ; self.life = particleLife || self.maxLife; self.bounces = 0; self.collisionImmunity = 0; self.isChargingParticle = false; // Reset charging flag self.tintColor = tintColor || 0xFFFFFF; graphics.tint = self.tintColor; graphics.alpha = 1; self.visible = false; };
Code edit (1 edits merged)
Please save this source code
User prompt
Update with: self.takeDamage = function (damageAmount) { self.health -= damageAmount; LK.getSound('hit').play(); LK.effects.flashObject(frameContainer, 0xff0000, 400); // Clean up any pending fireball timers if boss dies if (self.health <= 0) { if (self.currentFireballTimer) { LK.clearTimeout(self.currentFireballTimer); self.currentFireballTimer = null; } walkFrame1.alpha = 0; walkFrame2.alpha = 0; if (attackFrameAsset) { attackFrameAsset.alpha = 0; } return true; // Monster is killed } return false; // Monster survived };
Code edit (5 edits merged)
Please save this source code
User prompt
Update with: self.performFireballAttack = function (playerWorldX, playerWorldY) { if (!self.canShootFireball || !self.parent) { return; } console.log("Boss starting fireball attack!"); // Debug output self.canShootFireball = false; self.lastFireballTime = Date.now(); // ALWAYS trigger attack animation as warning if (!self.isAttacking) { self.performAttackAnimation(); } // Store target position when attack starts (so player can't just move after seeing animation) var targetX = playerWorldX; var targetY = playerWorldY; // Delay fireball spawn to give player warning time var fireballTimer = LK.setTimeout(function () { if (!self.parent) { return; // Boss was destroyed } console.log("Boss firing fireball after delay!"); // Debug output var fireball = new FireballProjectile(); var fireballStartX = self.mapX + Math.cos(player.dir) * 0.5; var fireballStartY = self.mapY + Math.sin(player.dir) * 0.5; var fireballStartZ = 0.8; // Fire at the position where player WAS when attack started (not current position) fireball.fire(fireballStartX, fireballStartY, fireballStartZ, targetX, targetY); projectiles.push(fireball); projectileLayer.addChild(fireball); }, 600); // Increased delay from 300ms to 600ms for more warning time // Reset cooldown with failsafe - but make sure timer reference is stored self.currentFireballTimer = fireballTimer; var resetCooldown = function() { if (self.parent) { self.canShootFireball = true; self.currentFireballTimer = null; console.log("Boss fireball cooldown reset"); // Debug output } }; LK.setTimeout(resetCooldown, self.fireballCooldown); };
User prompt
Replace with: self.performFireballAttack = function (playerWorldX, playerWorldY) { if (!self.canShootFireball || !self.parent) { return; } console.log("Boss firing fireball!"); // Debug output self.canShootFireball = false; self.lastFireballTime = Date.now(); // Only trigger attack animation if not already attacking if (!self.isAttacking) { self.performAttackAnimation(); } // Create fireball immediately (remove delay to prevent timing issues) var fireball = new FireballProjectile(); var fireballStartX = self.mapX + Math.cos(player.dir) * 0.5; var fireballStartY = self.mapY + Math.sin(player.dir) * 0.5; var fireballStartZ = 0.8; fireball.fire(fireballStartX, fireballStartY, fireballStartZ, playerWorldX, playerWorldY); projectiles.push(fireball); projectileLayer.addChild(fireball); // Reset cooldown with failsafe var resetCooldown = function() { if (self.parent) { self.canShootFireball = true; console.log("Boss fireball cooldown reset"); // Debug output } }; LK.setTimeout(resetCooldown, self.fireballCooldown); };
User prompt
Replace with: var MonsterAI = { moveTowardsPlayer: function moveTowardsPlayer(monster, playerX, playerY, map) { var monsterX = monster.mapX; var monsterY = monster.mapY; var dxToPlayer = playerX - monsterX; var dyToPlayer = playerY - monsterY; var distToPlayer = Math.sqrt(dxToPlayer * dxToPlayer + dyToPlayer * dyToPlayer); // Update monster's perception of player (line of sight) var hasClearLOS = false; if (distToPlayer <= MONSTER_AGGRO_RANGE) { var rayHit = castRayToPoint(monsterX, monsterY, playerX, playerY); hasClearLOS = !rayHit.hit || rayHit.dist >= distToPlayer - 0.1; } monster.canSeePlayer = hasClearLOS; var currentTimeForAI = Date.now(); // BOSS MONSTER SPECIFIC AI if (monster instanceof BossMonster) { var effectiveMoveSpeedBoss = typeof monster.moveSpeed !== 'undefined' ? monster.moveSpeed : 0.15; var moveDirXBoss = 0; var moveDirYBoss = 0; // BOSS CHASE LOGIC - more aggressive than regular monsters var bossShouldChase = false; if (distToPlayer <= MONSTER_AGGRO_RANGE * 1.5) { // Boss has longer aggro range bossShouldChase = true; // Boss always chases within extended range } // FIREBALL SHOOTING - with better error handling if (monster.canSeePlayer && distToPlayer <= MONSTER_AGGRO_RANGE) { // Reset canShootFireball if it's been too long (failsafe) if (!monster.canShootFireball && currentTimeForAI - monster.lastFireballTime > monster.fireballCooldown + 1000) { console.log("Boss fireball cooldown reset (failsafe)"); monster.canShootFireball = true; } if (monster.canShootFireball) { monster.performFireballAttack(playerX, playerY); } } // BOSS MOVEMENT LOGIC if (!bossShouldChase) { return; // Boss not chasing } if (distToPlayer < BOSS_TOO_CLOSE_DISTANCE) { // Back up if (distToPlayer > 0.1) { moveDirXBoss = -dxToPlayer / distToPlayer; moveDirYBoss = -dyToPlayer / distToPlayer; } } else if (distToPlayer > BOSS_PREFERRED_DISTANCE) { // Move closer if (distToPlayer > 0.1) { moveDirXBoss = dxToPlayer / distToPlayer; moveDirYBoss = dyToPlayer / distToPlayer; } } else { // Strafe - but only if we can see the player if (monster.canSeePlayer) { var perpendicularAngle = Math.atan2(dyToPlayer, dxToPlayer) + (Math.random() > 0.5 ? Math.PI / 2 : -Math.PI / 2); moveDirXBoss = Math.cos(perpendicularAngle); moveDirYBoss = Math.sin(perpendicularAngle); } else { // If we can't see player, move toward last known position moveDirXBoss = dxToPlayer / distToPlayer; moveDirYBoss = dyToPlayer / distToPlayer; } } // BOSS MOVEMENT EXECUTION with comprehensive fallbacks if (moveDirXBoss !== 0 || moveDirYBoss !== 0) { var potentialNewXBoss = monsterX + moveDirXBoss * effectiveMoveSpeedBoss; var potentialNewYBoss = monsterY + moveDirYBoss * effectiveMoveSpeedBoss; if (canMonsterMoveTo(potentialNewXBoss, potentialNewYBoss, monster)) { updateMonsterPosition(monster, potentialNewXBoss, potentialNewYBoss); } else { // Try X-only movement if (moveDirXBoss !== 0 && canMonsterMoveTo(monsterX + moveDirXBoss * effectiveMoveSpeedBoss, monsterY, monster)) { updateMonsterPosition(monster, monsterX + moveDirXBoss * effectiveMoveSpeedBoss, monsterY); } // Try Y-only movement else if (moveDirYBoss !== 0 && canMonsterMoveTo(monsterX, monsterY + moveDirYBoss * effectiveMoveSpeedBoss, monster)) { updateMonsterPosition(monster, monsterX, monsterY + moveDirYBoss * effectiveMoveSpeedBoss); } // Try alternative directions else { var alternativeAngles = [0, Math.PI/4, Math.PI/2, 3*Math.PI/4, Math.PI, 5*Math.PI/4, 3*Math.PI/2, 7*Math.PI/4]; for (var a = 0; a < alternativeAngles.length; a++) { var altX = monsterX + Math.cos(alternativeAngles[a]) * effectiveMoveSpeedBoss * 0.5; var altY = monsterY + Math.sin(alternativeAngles[a]) * effectiveMoveSpeedBoss * 0.5; if (canMonsterMoveTo(altX, altY, monster)) { updateMonsterPosition(monster, altX, altY); break; } } } } } return; // Boss AI complete } // REGULAR MONSTER AI (unchanged) var shouldChase = false; if (distToPlayer <= MONSTER_AGGRO_RANGE) { if (monster.canSeePlayer) { shouldChase = true; } else if (distToPlayer < MONSTER_CLOSE_PROXIMITY_RANGE) { shouldChase = true; } } if (!shouldChase) { monster.pathToPlayer = []; return; } monster.pathToPlayer = [{ x: playerX, y: playerY }]; var engagementDistance = MAX_MONSTER_PLAYER_ENGAGEMENT_DISTANCE; if (monster instanceof ImpMonster) { engagementDistance = 0.4; } if (distToPlayer <= engagementDistance) { if (monster.canAttack) { if ((monster instanceof EyeballMonster || monster instanceof ImpMonster) && !monster.isAttacking) { monster.performAttackAnimation(); } } return; } var effectiveMoveSpeed = typeof monster.moveSpeed !== 'undefined' ? monster.moveSpeed : 0.2; var moveDirX = 0; var moveDirY = 0; if (distToPlayer > 0) { moveDirX = dxToPlayer / distToPlayer; moveDirY = dyToPlayer / distToPlayer; } var potentialNewX = monsterX + moveDirX * effectiveMoveSpeed; var potentialNewY = monsterY + moveDirY * effectiveMoveSpeed; if (canMonsterMoveTo(potentialNewX, potentialNewY, monster)) { updateMonsterPosition(monster, potentialNewX, potentialNewY); } else { var slid = false; if (moveDirX !== 0 && canMonsterMoveTo(monsterX + moveDirX * effectiveMoveSpeed, monsterY, monster)) { updateMonsterPosition(monster, monsterX + moveDirX * effectiveMoveSpeed, monsterY); slid = true; } if (!slid && moveDirY !== 0 && canMonsterMoveTo(monsterX, monsterY + moveDirY * effectiveMoveSpeed, monster)) { updateMonsterPosition(monster, monsterX, monsterY + moveDirY * effectiveMoveSpeed); slid = true; } } } };
Code edit (1 edits merged)
Please save this source code
User prompt
As soon as the boss has line of sight, he should begin to shoot fireballs. No matter the range. If the player gets too close the boss should retreat, but should continue to fire fireballs if it’s off cooldown.
User prompt
The boss should be in fireball mode as soon as it has line of sight, regardless of the range. If the player gets too close, the boss should speed up and retreat. But continue to fire fireballs when they are on cooldown.
User prompt
Adjust boss pathfinding to track the player wherever they are in the level and pathfinding in order to keep firing fireballs.
User prompt
Change boss behavior to seek out player wherever they are in the map.
User prompt
Temporarily set player level to 5 at start for boss testing.
User prompt
Temporarily make only boss levels for testing.
User prompt
Temporarily only make arena map selected for boss testing.
User prompt
Boss monster will still follow players but will hold a farther distance and back up aa necessary and shoot fireball projectiles at the player if it has line of sight.
User prompt
Boss monster will still follow players but will hold a farther distance and back up aa necessary and shoot fireball projectiles at the player if it has line of sight.
User prompt
Create a boss monster that uses the bosswalk1 and 2 for walking animation and the bossattack for attack animation. Give it 15 health.
User prompt
Take the arena type map out of regular selection and it will automatically be selected every 5 levels. This will be a boss level and no other enemies will spawn.
Code edit (1 edits merged)
Please save this source code
/**** * Plugins ****/ var tween = LK.import("@upit/tween.v1"); var storage = LK.import("@upit/storage.v1"); /**** * Classes ****/ var ControlButton = Container.expand(function (direction) { var self = Container.call(this); var buttonSprite = self.attachAsset('controlButton', { anchorX: 0.5, anchorY: 0.5, scaleX: 2.5, scaleY: 2.5 }); var arrowSprite = self.attachAsset('buttonArrow', { anchorX: 0.5, anchorY: 0.5, scaleX: 2.0, scaleY: 2.0 }); // Set arrow direction based on button type if (direction === 'up') { arrowSprite.rotation = 0; } else if (direction === 'right') { arrowSprite.rotation = Math.PI / 2; } else if (direction === 'down') { arrowSprite.rotation = Math.PI; } else if (direction === 'left') { arrowSprite.rotation = Math.PI * 1.5; } else if (direction === 'attack') { arrowSprite.visible = false; buttonSprite = self.attachAsset('attackButton', { anchorX: 0.5, anchorY: 0.5, scaleX: 3.5, scaleY: 3.5 }); } self.direction = direction; self.pressed = false; self.updateCooldown = function (ratio) { if (self.direction === 'attack') { // If in interact mode, change color and label if (self.isInteract) { buttonSprite.tint = 0x33aa33; // Green for interact // Optionally, overlay a text label (not required, but for clarity) if (!self.interactLabel) { self.interactLabel = new Text2('INTERACT', { size: 40, fill: 0xFFFFFF }); self.interactLabel.anchor.set(0.5, 0.5); self.interactLabel.y = 0; self.addChild(self.interactLabel); } self.interactLabel.visible = true; buttonSprite.alpha = 1; } else { buttonSprite.tint = 0xaa3333; // Default attack color if (self.interactLabel) { self.interactLabel.visible = false; } // Update the button alpha based on cooldown ratio (0 to 1) buttonSprite.alpha = 0.3 + ratio * 0.7; } } }; self.down = function (x, y, obj) { self.pressed = true; buttonSprite.alpha = 0.7; if (self.direction === 'attack') { // Stop propagation to prevent the event from affecting the joystick obj.stopPropagation = true; // If joystick is active, store its current state for override if (controlButtons && controlButtons.joystick && controlButtons.joystick.active) { joystickOverrideActive = true; joystickOverrideX = controlButtons.joystick.normalizedX; joystickOverrideY = controlButtons.joystick.normalizedY; } else { // Ensure override is not active if joystick wasn't active at press time joystickOverrideActive = false; } // If interact mode, handle based on interactTarget type if (self.isInteract && self.interactTarget) { if (self.interactTarget instanceof Treasure && !self.interactTarget.isOpen) { var treasureToOpen = self.interactTarget; treasureToOpen.openTreasure(); // Play sound LK.getSound('collect').play(); // Award points player.score += treasureToOpen.value * 5; updateUI(); // Spawn a random powerup at the treasure's location, with animation var t = treasureToOpen; // Use the correct treasure object var powerUpTypes = ['speed', 'defense', 'health', 'attack']; var randomType = powerUpTypes[Math.floor(Math.random() * powerUpTypes.length)]; var powerUpFromChest = new PowerUp(randomType); //{A} // Renamed to avoid conflict // Place at the center of the chest (mapX/Y are world coords, will be projected in renderEntities) powerUpFromChest.mapX = t.mapX; powerUpFromChest.mapY = t.mapY; powerUpFromChest.collected = false; // Set initial multiplier for spawn animation. Scale itself will be handled by renderEntities. powerUpFromChest.spawnScaleMultiplier = 0.1; // Initialize new animation properties powerUpFromChest.verticalOffset = 0; powerUpFromChest.currentBobOffset = 0; powerUpFromChest.hasReachedBobHeight = false; powerUps.push(powerUpFromChest); if (typeof dynamicEntitiesContainer !== "undefined") { dynamicEntitiesContainer.addChild(powerUpFromChest); } // Combined Animation Sequence: Scale up and Lift tween(powerUpFromChest, { spawnScaleMultiplier: 1.0, verticalOffset: 0.10 // Lift by 0.34 world units (reduced by 15%) }, { duration: 1200, // Slower combined animation (1200ms) easing: tween.elasticOut, // Retains a nice "pop" onFinish: function onFinish() { // Start bobbing after combined animation powerUpFromChest.hasReachedBobHeight = true; powerUpFromChest.startBobbingAnimation(); } }); // Remove treasure from map after a short delay (for open animation) LK.setTimeout(function () { map[Math.floor(t.mapY)][Math.floor(t.mapX)].removeTreasure(); // Do NOT destroy the treasure object, just leave it open and non-collidable // Remove from treasures array so it doesn't show interact again var idx = treasures.indexOf(t); if (idx !== -1) { treasures.splice(idx, 1); } checkLevelCompletion(); }, 500); } else if (self.interactTarget instanceof PowerUp && !self.interactTarget.collected) { var powerUpToCollect = self.interactTarget; powerUpToCollect.applyAndRemove(); // No specific score or immediate level completion for collecting power-ups. } else if (self.interactTarget instanceof Fountain) { var fountainToUse = self.interactTarget; fountainToUse.interact(); // Sound, message, and health refill are handled within fountain.interact() } } else if (canAttack) { attackAction(); } } }; self.up = function (x, y, obj) { self.pressed = false; if (self.direction === 'attack') { // Clear joystick override when attack button is released joystickOverrideActive = false; if (!canAttack) { buttonSprite.alpha = 0.3; // Show as disabled } else { buttonSprite.alpha = 1; } } else { buttonSprite.alpha = 1; } }; return self; }); var EyeballMonster = Container.expand(function () { var self = Container.call(this); self.flySprite = self.attachAsset('eyeballfly', { anchorX: 0.5, anchorY: 0.5 }); self.attackSprite = self.attachAsset('eyeballattack', { anchorX: 0.5, anchorY: 0.5, alpha: 0 }); self.health = 2; // Fixed health as per requirement self.mapX = 0; self.mapY = 0; self.moveSpeed = 0.25; // Eyeballs move a bit faster than default monsters // Properties for attack visuals and game logic compatibility self.isAttacking = false; // True if currently in attack animation self.attackDuration = 600; // Duration of the attack visual animation in ms self.attackCooldown = 2000; // Cooldown for dealing damage (used by checkMonsterCollisions) self.lastAttackTime = 0; // Timestamp of last damage dealt (used by checkMonsterCollisions) self.canAttack = true; // Whether this monster can deal damage (used by checkMonsterCollisions) // Bobbing properties self.baseWorldYOffset = 0.4; // Base height above ground (world units) // currentBobbingYOffset, bobAmplitude, and bobSpeed removed as bobbing is disabled. // For MonsterAI compatibility (canSeePlayer, pathToPlayer might be set by AI) self.canSeePlayer = false; self.pathToPlayer = []; self.lastMoveTime = 0; // For AI movement frequency or other logic // startBobbing method removed as bobbing animation is disabled. // This function is called by MonsterAI to trigger the attack *animation* self.performAttackAnimation = function () { if (self.isAttacking || !self.parent) { // Already visually attacking or destroyed return; } self.isAttacking = true; // tween.stop for currentBobbingYOffset removed as bobbing is disabled. // Switch to attack sprite by directly setting alpha self.attackSprite.alpha = 1; self.flySprite.alpha = 0; // After attack visual duration, revert to fly sprite LK.setTimeout(function () { if (!self.parent) { return; } // Check if still exists // Revert to fly sprite by directly setting alpha self.attackSprite.alpha = 0; self.flySprite.alpha = 1; self.isAttacking = false; // self.startBobbing() call removed as bobbing is disabled. }, self.attackDuration); }; self.takeDamage = function (damageAmount) { self.health -= damageAmount; LK.getSound('hit').play(); // Flash the whole container (both sprites are children) LK.effects.flashObject(self, 0xff0000, 400); if (self.health <= 0) { // tween.stop for currentBobbingYOffset removed as bobbing is disabled. // Ensure attack sprite is hidden if dying mid-attack animation if (self.attackSprite) { self.attackSprite.alpha = 0; } if (self.flySprite) { self.flySprite.alpha = 0; } // Or make it fade out return true; // Monster is killed } return false; // Monster survived }; // This is called by the main game loop's monster iteration. // For EyeballMonster, its primary "idle" animation (bobbing) is tween-based and self-managed. // The attack animation is triggered by performAttackAnimation. // So, this can be empty or used for other per-tick logic if needed. self.updateAnimation = function () { // Bobbing is handled by tweens started in startBobbing. // Attack sprite switching is handled by performAttackAnimation. }; return self; }); var FloorCaster = Container.expand(function () { var self = Container.call(this); // Screen dimensions var SCREEN_WIDTH = 2048; var SCREEN_HEIGHT = 2732; var HORIZON_Y = SCREEN_HEIGHT / 2; // Create containers for floor and ceiling strips var floorContainer = new Container(); var ceilingContainer = new Container(); self.addChild(floorContainer); self.addChild(ceilingContainer); // Store strip pools to avoid creating/destroying objects var floorStrips = []; var ceilingStrips = []; var stripHeight = 4; // Height of each horizontal strip var numStrips = Math.ceil(HORIZON_Y / stripHeight); // Initialize floor and ceiling rendering self.update = function (playerX, playerY, playerDir) { // Clear existing strips floorContainer.removeChildren(); ceilingContainer.removeChildren(); // Enhanced shading parameters var SHADE_START_FACTOR = 0.3; // Start shading even sooner (0.5 instead of 0.7) var FLOOR_MIN_SHADE = 0.28; // Slightly darker minimum for floor var CEILING_MIN_SHADE = 0.18; // Darker minimum for ceiling var WALL_BASE_GRADIENT = 0.20; // Add gradient near walls // Render floor (bottom half of screen) var stripIndex = 0; for (var y = HORIZON_Y; y < SCREEN_HEIGHT; y += stripHeight) { // Calculate distance from horizon var distanceFromHorizon = y - HORIZON_Y; if (distanceFromHorizon <= 0) { continue; } // Calculate world distance for perspective var worldDistance = WALL_HEIGHT_FACTOR / distanceFromHorizon; // Skip if too far away if (worldDistance > MAX_RENDER_DISTANCE) { continue; } // Calculate enhanced shade based on distance (darker = further) var distanceRatio = worldDistance / MAX_RENDER_DISTANCE; var shadeFactor = Math.max(FLOOR_MIN_SHADE, 1 - distanceRatio / SHADE_START_FACTOR); // Wall base gradient - darker near the horizon/walls var horizonProximity = 1 - distanceFromHorizon / (HORIZON_Y * 0.3); if (horizonProximity > 0) { shadeFactor -= horizonProximity * WALL_BASE_GRADIENT; } // Create or reuse floor strip var floorStrip; if (stripIndex < floorStrips.length) { floorStrip = floorStrips[stripIndex]; } else { floorStrip = LK.getAsset('mapFloor', { anchorX: 0, anchorY: 0 }); floorStrips.push(floorStrip); } // Position and scale the strip floorStrip.x = 0; floorStrip.y = y; floorStrip.width = SCREEN_WIDTH; floorStrip.height = stripHeight; floorStrip.alpha = shadeFactor; // Add to floor container floorContainer.addChild(floorStrip); stripIndex++; } // Render ceiling (top half of screen) stripIndex = 0; for (var y = HORIZON_Y - stripHeight; y >= 0; y -= stripHeight) { // Calculate distance from horizon var distanceFromHorizon = HORIZON_Y - y; if (distanceFromHorizon <= 0) { continue; } // Calculate world distance for perspective var worldDistance = WALL_HEIGHT_FACTOR / distanceFromHorizon; // Skip if too far away if (worldDistance > MAX_RENDER_DISTANCE) { continue; } // Calculate enhanced shade for ceiling (darker than floor) var distanceRatio = worldDistance / MAX_RENDER_DISTANCE; var shadeFactor = Math.max(CEILING_MIN_SHADE, 1 - distanceRatio / SHADE_START_FACTOR); // Add subtle gradient near ceiling/walls var horizonProximity = 1 - distanceFromHorizon / (HORIZON_Y * 0.3); if (horizonProximity > 0) { shadeFactor -= horizonProximity * WALL_BASE_GRADIENT; } // Create or reuse ceiling strip var ceilingStrip; if (stripIndex < ceilingStrips.length) { ceilingStrip = ceilingStrips[stripIndex]; } else { ceilingStrip = LK.getAsset('ceiling', { anchorX: 0, anchorY: 0 }); ceilingStrips.push(ceilingStrip); } // Position and scale the strip ceilingStrip.x = 0; ceilingStrip.y = y; ceilingStrip.width = SCREEN_WIDTH; ceilingStrip.height = stripHeight; ceilingStrip.alpha = shadeFactor; // Add to ceiling container ceilingContainer.addChild(ceilingStrip); stripIndex++; } }; return self; }); var Fountain = Container.expand(function () { var self = Container.call(this); self.sprite = self.attachAsset('fountain1', { anchorX: 0.5, anchorY: 0.5 }); self.mapX = 0; self.mapY = 0; self.state = 'full'; // 'full' or 'empty' self.isInteractable = true; self.particleTimer = 0; self.particleInterval = 4; // Spawn particles a bit frequently for a nice effect self.init = function (mapX, mapY) { self.mapX = mapX; self.mapY = mapY; self.sprite.tint = 0xFFFFFF; // Start with a bright tint }; self.updateParticles = function () { if (self.state === 'full' && self.visible && particlePoolManager && particleExplosionContainer) { self.particleTimer++; if (self.particleTimer >= self.particleInterval) { self.particleTimer = 0; var count = 1 + Math.floor(Math.random() * 2); // 1 or 2 particles per emission for (var i = 0; i < count; i++) { var particle = particlePoolManager.getParticle(); if (!particle) { continue; } var emissionWorldX = self.mapX; var emissionWorldY = self.mapY; // Assuming the fountain model's "nozzle" is around 1.0 world unit high from its base. var emissionWorldZ = 1.0; // Simulate a world-relative radial splash var splashAngle = Math.random() * Math.PI * 2; // Full circle for radial splash var horizontalSpeed = Math.random() * 0.0020 + 0.0005; // Small horizontal speed for gentle splash var vx = Math.cos(splashAngle) * horizontalSpeed; var vy = Math.sin(splashAngle) * horizontalSpeed; var vz = Math.random() * 0.018 + 0.012; // Gentle upward velocity var life = 50 + Math.random() * 40; // Shorter life for fountain particles var tintColor = 0x66B2FF; // Light blue, slightly less intense particle.init(emissionWorldX, emissionWorldY, emissionWorldZ, vx, vy, vz, life, tintColor); particle.collisionImmunity = 3; particleExplosionContainer.addChild(particle); } } } }; self.interact = function () { if (self.state === 'full' && self.isInteractable) { self.state = 'empty'; self.isInteractable = false; self.sprite.tint = 0x707070; // Darken the fountain significantly player.health = player.maxHealth; updateUI(); showPowerUpEffectText("Your health has been fully restored!"); LK.getSound('collect').play(); } }; return self; }); var Gate = Container.expand(function () { var self = Container.call(this); // Create gate visual using existing wall asset but with a different color var gateSprite = self.attachAsset('wall', { anchorX: 0.5, anchorY: 0.5 }); // Make the gate distinct with a green tint gateSprite.tint = 0x00FF00; self.gateSprite = gateSprite; self.mapX = 0; self.mapY = 0; // Pulse animation to make gate more visible var _animateGate = function animateGate() { tween(gateSprite, { alpha: 0.7, scaleX: 1.1, scaleY: 1.1 }, { duration: 1000, onFinish: function onFinish() { tween(gateSprite, { alpha: 1, scaleX: 1.0, scaleY: 1.0 }, { duration: 1000, onFinish: _animateGate }); } }); }; // Start the pulsing animation _animateGate(); return self; }); var ImpMonster = Container.expand(function () { var self = Container.call(this); var frameContainer = new Container(); self.addChild(frameContainer); // Scale imp assets to be visually as tall as the Ogre monster (approx 170px effective height) // impwalk1 original height: 200px. Scale: 170/200 = 0.85 var impScale = 0.85; self.sizeMultiplier = 0.7; var walkFrame1 = LK.getAsset('impwalk1', { anchorX: 0.5, anchorY: 0.5, scaleX: impScale, scaleY: impScale }); var walkFrame2 = LK.getAsset('impwalk2', { anchorX: 0.5, anchorY: 0.5, scaleX: impScale, scaleY: impScale }); var attackFrameAsset = LK.getAsset('impattack', { anchorX: 0.5, anchorY: 0.5, scaleX: impScale, scaleY: impScale, alpha: 0 }); frameContainer.addChild(walkFrame1); frameContainer.addChild(walkFrame2); frameContainer.addChild(attackFrameAsset); walkFrame1.alpha = 1; walkFrame2.alpha = 0; self.currentFrame = 1; // 1 for walkFrame1, 2 for walkFrame2 self.animationTick = 0; self.mapX = 0; self.mapY = 0; self.health = 1; // Ogre monster has 6, Eyeball has 2 self.moveSpeed = 0.3; // Ogre default 0.2, Eyeball 0.25. Imp is faster. self.lastMoveTime = 0; self.canSeePlayer = false; self.pathToPlayer = []; // Attack properties self.attackCooldown = 1500; // ms (Ogre/Eyeball 2000ms). Imp attacks faster. self.lastAttackTime = 0; self.canAttack = true; // Cooldown flag for dealing damage self.isAttacking = false; // Animation state flag self.attackDuration = 500; // ms for the attack visual (Eyeball 600ms) self.updateAnimation = function () { if (self.isAttacking) { // If currently in attack animation, don't update walk animation return; } self.animationTick++; // Change animation frame every 15 ticks (matches Ogre monster animation speed) if (self.animationTick >= 15) { self.animationTick = 0; self.currentFrame = self.currentFrame === 1 ? 2 : 1; if (self.currentFrame === 1) { walkFrame1.alpha = 1; walkFrame2.alpha = 0; } else { walkFrame1.alpha = 0; walkFrame2.alpha = 1; } } }; self.performAttackAnimation = function () { if (self.isAttacking || !self.parent) { // Already visually attacking or destroyed return; } self.isAttacking = true; // Hide walking frames, show attack frame walkFrame1.alpha = 0; walkFrame2.alpha = 0; attackFrameAsset.alpha = 1; LK.setTimeout(function () { if (!self.parent) { // Check if still exists return; } attackFrameAsset.alpha = 0; // Hide attack frame // Revert to the current walking frame (don't reset walk animation cycle) if (self.currentFrame === 1) { walkFrame1.alpha = 1; walkFrame2.alpha = 0; // Ensure other walk frame is hidden } else { // currentFrame is 2 walkFrame1.alpha = 0; // Ensure other walk frame is hidden walkFrame2.alpha = 1; } self.isAttacking = false; }, self.attackDuration); }; self.takeDamage = function (damageAmount) { self.health -= damageAmount; LK.getSound('hit').play(); // Flash the frameContainer which holds all visual sprites for the imp LK.effects.flashObject(frameContainer, 0xff0000, 400); if (self.health <= 0) { // Ensure all sprites are hidden if dying to prevent lingering visuals walkFrame1.alpha = 0; walkFrame2.alpha = 0; if (attackFrameAsset) { attackFrameAsset.alpha = 0; } return true; // Monster is killed } return false; // Monster survived }; return self; }); var JoystickController = Container.expand(function () { var self = Container.call(this); // Create joystick base var baseRadius = 150; var baseSprite = self.attachAsset('controlButton', { anchorX: 0.5, anchorY: 0.5, scaleX: 5.0, scaleY: 5.0, alpha: 0.4 }); // Create joystick handle var handleRadius = 100; var handleSprite = self.attachAsset('controlButton', { anchorX: 0.5, anchorY: 0.5, scaleX: 3.0, scaleY: 3.0 }); // Initialize variables self.active = false; self.startX = 0; self.startY = 0; self.maxDistance = baseRadius; self.normalizedX = 0; self.normalizedY = 0; // Reset the joystick handle position self.resetHandle = function () { handleSprite.x = 0; handleSprite.y = 0; self.normalizedX = 0; self.normalizedY = 0; self.active = false; }; // Handle touch down event self.down = function (x, y, obj) { self.active = true; self.startX = x; self.startY = y; }; // Handle touch move event self.move = function (x, y, obj) { if (!self.active) { return; } // Calculate distance from start position var dx = x - self.startX; var dy = y - self.startY; var distance = Math.sqrt(dx * dx + dy * dy); // Normalize the distance to get direction vector if (distance > 0) { // Clamp to max distance if (distance > self.maxDistance) { dx = dx * self.maxDistance / distance; dy = dy * self.maxDistance / distance; distance = self.maxDistance; } // Set handle position handleSprite.x = dx; handleSprite.y = dy; // Calculate normalized values (-1 to 1) self.normalizedX = dx / self.maxDistance; self.normalizedY = dy / self.maxDistance; } else { self.resetHandle(); } }; // Handle touch up event self.up = function (x, y, obj) { self.resetHandle(); }; // Initialize with handle at center self.resetHandle(); return self; }); var MapCell = Container.expand(function () { var self = Container.call(this); self.type = 0; // 0 = floor, 1 = wall self.monster = null; self.treasure = null; self.gate = null; self.setType = function (type) { self.type = type; self.updateVisual(); }; self.updateVisual = function () { self.removeChildren(); if (self.type === 1) { self.attachAsset('mapWall', { anchorX: 0, anchorY: 0 }); } else { self.attachAsset('mapFloor', { anchorX: 0, anchorY: 0 }); } }; self.addMonster = function () { if (self.type === 0 && !self.monster && !self.treasure && !self.gate) { self.monster = true; return true; } return false; }; self.addTreasure = function () { if (self.type === 0 && !self.monster && !self.treasure && !self.gate) { self.treasure = true; return true; } return false; }; self.removeMonster = function () { self.monster = null; }; self.removeTreasure = function () { self.treasure = null; }; self.addGate = function () { if (self.type === 0 && !self.monster && !self.treasure && !self.gate) { // Gate check is already there, good. self.gate = true; return true; } return false; }; self.removeGate = function () { self.gate = null; }; return self; }); var Monster = Container.expand(function () { var self = Container.call(this); // Create animation frame container var frameContainer = new Container(); self.addChild(frameContainer); // Create monster animation frames var monsterFrame1 = LK.getAsset('demonOgreWalk1', { anchorX: 0.5, anchorY: 0.5 }); var monsterFrame2 = LK.getAsset('demonOgreWalk2', { anchorX: 0.5, anchorY: 0.5 }); // Add frames to container frameContainer.addChild(monsterFrame1); frameContainer.addChild(monsterFrame2); // Set initial visibility monsterFrame1.alpha = 1; monsterFrame2.alpha = 0; // Initialize animation state self.currentFrame = 1; self.animationTick = 0; self.mapX = 0; self.mapY = 0; self.health = 6; self.lastMoveTime = 0; self.canSeePlayer = false; self.pathToPlayer = []; self.attackCooldown = 2000; // 2 seconds between attacks self.lastAttackTime = 0; // When monster last attacked self.canAttack = true; // Whether monster can attack // Update animation frames - will be called from game loop self.updateAnimation = function () { self.animationTick++; // Change animation frame every 30 ticks (half second at 60fps) if (self.animationTick >= 15) { self.animationTick = 0; self.currentFrame = self.currentFrame === 1 ? 2 : 1; // Directly change alpha values without tween if (self.currentFrame === 1) { monsterFrame1.alpha = 1; monsterFrame2.alpha = 0; } else { monsterFrame1.alpha = 0; monsterFrame2.alpha = 1; } } }; self.takeDamage = function (damageAmount) { self.health -= damageAmount; LK.getSound('hit').play(); // Visual feedback for hit - flash the monster red LK.effects.flashObject(frameContainer, 0xff0000, 400); return self.health <= 0; }; return self; }); var Particle = Container.expand(function () { var self = Container.call(this); var graphics = self.attachAsset('particle', { anchorX: 0.5, anchorY: 0.5 }); // World coordinates and velocities self.worldX = 0; self.worldY = 0; self.worldZ = 0; // Height above the dungeon floor self.vx = 0; // World velocity X self.vy = 0; // World velocity Y self.vz = 0; // World velocity Z (vertical) self.life = 0; self.maxLife = 480; // Approx 8 seconds at 60 FPS (Doubled) self.bounces = 0; self.tintColor = 0xFFFFFF; // Default tint // Particle physics constants (world-based) var PARTICLE_WORLD_GRAVITY_EFFECT = 0.0035; // Gravity pulling worldZ down (Reduced by half) var PARTICLE_Z_DAMPING = 0.6; // Energy retained vertically after floor bounce (Increased for higher bounce) var PARTICLE_XY_DAMPING = 0.4; // Energy retained horizontally after wall bounce (Reduced from 0.6) var PARTICLE_GROUND_FRICTION = 0.8; // Friction for vx, vy when hitting floor (Reduced from 0.9) var PARTICLE_MAX_BOUNCES = 4; var PARTICLE_VISUAL_SIZE_AT_UNIT_DISTANCE = 0.08; // Visual size (in world units) when 1 unit away var MAX_RENDER_DISTANCE_PARTICLES = 12; // Max distance to render particles self.init = function (wX, wY, wZ, velX, velY, velZ, particleLife, tintColor) { self.worldX = wX; self.worldY = wY; self.worldZ = wZ; self.vx = velX; self.vy = velY; self.vz = velZ; self.life = particleLife || self.maxLife; self.bounces = 0; self.collisionImmunity = 0; // No immunity by default self.tintColor = tintColor || 0xFFFFFF; // Store and apply tint graphics.tint = self.tintColor; graphics.alpha = 1; // Initial scale will be set in update based on distance self.visible = false; // Will be set true if in FOV during update }; self.update = function () { if (self.life <= 0 || self.bounces >= PARTICLE_MAX_BOUNCES) { self.visible = false; return false; // Indicate inactive } // Store pre-move position for collision response var prevWorldX = self.worldX; var prevWorldY = self.worldY; // Apply world velocities self.worldX += self.vx; self.worldY += self.vy; self.worldZ += self.vz; // Apply gravity to vertical velocity self.vz -= PARTICLE_WORLD_GRAVITY_EFFECT; // Floor bounce logic (worldZ = 0 is the floor) if (self.worldZ < 0) { self.worldZ = 0; self.vz *= -PARTICLE_Z_DAMPING; self.vx *= PARTICLE_GROUND_FRICTION; self.vy *= PARTICLE_GROUND_FRICTION; self.bounces++; if (Math.abs(self.vz) < 0.005 && PARTICLE_WORLD_GRAVITY_EFFECT > Math.abs(self.vz)) { // Come to rest self.vz = 0; } } // Wall bounce logic - skip if particle has collision immunity if (self.collisionImmunity > 0) { self.collisionImmunity--; } else { var currentMapX = Math.floor(self.worldX); var currentMapY = Math.floor(self.worldY); if (currentMapX < 0 || currentMapX >= MAP_SIZE || currentMapY < 0 || currentMapY >= MAP_SIZE || map[currentMapY] && map[currentMapY][currentMapX] && map[currentMapY][currentMapX].type === 1) { self.worldX = prevWorldX; // Revert to position before entering wall self.worldY = prevWorldY; var pt_map_prev_x = Math.floor(prevWorldX); var pt_map_prev_y = Math.floor(prevWorldY); var reflectedX = false; var reflectedY = false; // Check if collision was primarily due to X movement into a wall if (currentMapX !== pt_map_prev_x && map[pt_map_prev_y] && map[pt_map_prev_y][currentMapX] && map[pt_map_prev_y][currentMapX].type === 1) { self.vx *= -PARTICLE_XY_DAMPING; reflectedX = true; } // Check if collision was primarily due to Y movement into a wall if (currentMapY !== pt_map_prev_y && map[currentMapY] && map[currentMapY][pt_map_prev_x] && map[currentMapY][pt_map_prev_x].type === 1) { self.vy *= -PARTICLE_XY_DAMPING; reflectedY = true; } // If hit a corner or exact cause is ambiguous (e.g. started in wall), reflect based on dominant velocity or both if (!reflectedX && !reflectedY) { // This case implies it was already in a wall or hit a corner perfectly. // A simple heuristic: reflect the larger velocity component, or both if similar. if (Math.abs(self.vx) > Math.abs(self.vy) * 1.2) { self.vx *= -PARTICLE_XY_DAMPING; } else if (Math.abs(self.vy) > Math.abs(self.vx) * 1.2) { self.vy *= -PARTICLE_XY_DAMPING; } else { // Similar magnitude or started in wall, reflect both self.vx *= -PARTICLE_XY_DAMPING; self.vy *= -PARTICLE_XY_DAMPING; } } self.bounces++; } } // Calculate screen position and scale (similar to projectiles/monsters) var dx = self.worldX - player.x; var dy = self.worldY - player.y; var distToPlayerPlane = Math.sqrt(dx * dx + dy * dy); if (distToPlayerPlane < 0.1) { distToPlayerPlane = 0.1; } // Avoid division by zero / extreme scales var angle = Math.atan2(dy, dx) - player.dir; while (angle < -Math.PI) { angle += Math.PI * 2; } while (angle > Math.PI) { angle -= Math.PI * 2; } if (Math.abs(angle) < HALF_FOV && distToPlayerPlane < MAX_RENDER_DISTANCE_PARTICLES) { self.visible = true; // Screen X self.x = 2048 / 2 + angle / HALF_FOV * (2048 / 2); // Screen Y: based on horizon, distance, and particle's worldZ var screenY_horizon = 2732 / 2; var z_to_screen_pixels_factor = WALL_HEIGHT_FACTOR; // Factor for scaling worldZ to screen pixels, effectively how many screen pixels 1 world unit of height is at 1 world unit of distance. var player_camera_height_projection_factor = WALL_HEIGHT_FACTOR * 0.5; // This factor determines how the floor plane "drops" from the horizon based on distance, simulating camera height. // Using 0.5 of WALL_HEIGHT_FACTOR aligns it with how treasure bottoms are projected. // Calculate the screen Y position for a point that is ON THE FLOOR (i.e., worldZ = 0) // at the particle's current distance (distToPlayerPlane). // Closer points on the floor will have a larger Y value (lower on screen). var screenY_for_floor_at_dist = screenY_horizon + player_camera_height_projection_factor / distToPlayerPlane; // Calculate the screen Y offset caused by the particle's actual height (self.worldZ) above the floor. // This offset is relative to the screenY_for_floor_at_dist. var screenY_offset_due_to_worldZ = self.worldZ * z_to_screen_pixels_factor / distToPlayerPlane; // The final screen Y is the floor's projected Y minus the offset due to the particle's height. self.y = screenY_for_floor_at_dist - screenY_offset_due_to_worldZ; // Scale based on distance and particle's inherent visual size var particleAssetBaseSize = 100.0; // Updated for 'particle' asset (100x100) var effectiveScreenSize = PARTICLE_VISUAL_SIZE_AT_UNIT_DISTANCE * WALL_HEIGHT_FACTOR / distToPlayerPlane; var screenScaleFactor = Math.max(0.05, effectiveScreenSize / particleAssetBaseSize); graphics.scale.set(screenScaleFactor); var lifeRatio = Math.max(0, self.life / self.maxLife); graphics.alpha = lifeRatio * 0.8 + 0.2; // Fade out but maintain some visibility } else { self.visible = false; } self.life--; if (self.life <= 0 || self.bounces >= PARTICLE_MAX_BOUNCES) { self.visible = false; return false; // Indicate inactive } return true; // Indicate active }; return self; }); var PowerUp = Container.expand(function (type) { var self = Container.call(this); self.powerUpType = type; // 'speed', 'defense', 'health', 'attack' self.spawnScaleMultiplier = 1.0; // Used for spawn animation, defaults to full size self.mapX = 0; self.mapY = 0; self.collected = false; self.verticalOffset = 0; // World units for lift from chest self.currentBobOffset = 0; // Screen pixels for bobbing motion self.hasReachedBobHeight = false; // Flag to start bobbing var assetId = ''; switch (self.powerUpType) { case 'speed': assetId = 'speedup'; break; case 'defense': assetId = 'armorup'; break; case 'health': assetId = 'healthup'; break; case 'attack': assetId = 'attackup'; break; default: console.error("Unknown power-up type: " + self.powerUpType); assetId = 'treasure'; // Fallback, should not happen break; } var powerUpSprite = self.attachAsset(assetId, { anchorX: 0.5, anchorY: 0.5 }); self.powerUpSprite = powerUpSprite; // Expose the sprite self.startBobbingAnimation = function () { if (self.collected || !self.parent || !self.hasReachedBobHeight) { return; } var bobAmplitude = 15; // Screen pixels for bobbing up/down var bobSpeed = 1800; // Milliseconds for a full bob cycle ( थोड़ा धीमा ) function animateUpInternal() { if (self.collected || !self.parent) { return; } // Check again before starting tween tween(self, { currentBobOffset: -bobAmplitude }, { duration: bobSpeed / 2, easing: tween.easeInOut, onFinish: function onFinish() { if (self.collected || !self.parent) { return; } animateDownInternal(); } }); } function animateDownInternal() { if (self.collected || !self.parent) { return; } // Check again tween(self, { currentBobOffset: 0 }, { // Bob back to its lifted base duration: bobSpeed / 2, easing: tween.easeInOut, onFinish: function onFinish() { if (self.collected || !self.parent) { return; } animateUpInternal(); } }); } animateUpInternal(); // Start the bobbing cycle }; // Note: _animateBob() call removed, startBobbingAnimation will be triggered after lift. self.applyAndRemove = function () { if (self.collected) { return; } self.collected = true; var effectMessage = ""; // Apply power-up effect (now permanent) switch (self.powerUpType) { case 'health': player.maxHealth += 1; player.health = Math.min(player.health + 1, player.maxHealth); effectMessage = "Max Health +1\nHealth Refilled +1"; updateUI(); break; case 'speed': player.moveSpeedMultiplier += 0.15; // Smaller, incremental speed boost effectMessage = "Movement Speed Increased!"; break; case 'defense': player.defenseBuffActive = true; // Permanent defense buff effectMessage = "Defense Increased!"; break; case 'attack': player.attackPower += 1; // Increase attack power // player.attackBuffActive is no longer set by this power-up, as it affects power not speed/cooldown. effectMessage = "Attack Power Increased!"; break; } if (effectMessage) { showPowerUpEffectText(effectMessage); } LK.getSound('collect').play(); // Visual feedback for collection (quick scale out and fade) tween(self, { scaleX: self.scale.x * 0.1, scaleY: self.scale.y * 0.1, alpha: 0 }, { duration: 300, easing: tween.easeIn, // Make it disappear inwards onFinish: function onFinish() { if (self.parent) { self.parent.removeChild(self); } var index = powerUps.indexOf(self); if (index !== -1) { powerUps.splice(index, 1); } self.destroy(); // Explicitly destroy after removal } }); }; return self; }); var Projectile = Container.expand(function () { var self = Container.call(this); var projectileSprite = self.attachAsset('projectile', { anchorX: 0.5, anchorY: 0.5 }); // World position and movement properties self.worldX = 0; self.worldY = 0; self.dirX = 0; self.dirY = 0; self.speed = 0.0375; // World units per tick (reduced to 1/4 of original) self.active = false; self.distance = 0; self.maxDistance = 10; // Maximum travel distance in world units // Initialize projectile self.fire = function (screenX, screenY) { // Start at player position, slightly offset in the firing direction self.worldX = player.x + Math.cos(player.dir) * 0.3; self.worldY = player.y + Math.sin(player.dir) * 0.3; // Direction is player's current facing direction self.dirX = Math.cos(player.dir); self.dirY = Math.sin(player.dir); self.active = true; self.distance = 0; self.hitMonsters = []; // Initialize list to track hit monsters // Play attack sound LK.getSound('attack').play(); // Scale for visual effect projectileSprite.scale.set(0.2, 0.2); // Store initial screen coordinates from hand self.initialScreenX = screenX || 2048 / 2; self.initialScreenY = screenY || 2732 - 300; // Set initial position to hand location self.x = self.initialScreenX; self.y = self.initialScreenY; self.visible = true; }; // Update projectile position and check for collisions self.update = function (deltaTime) { if (!self.active) { return false; } // Move projectile in world space self.worldX += self.dirX * self.speed; self.worldY += self.dirY * self.speed; // Track distance traveled self.distance += self.speed; // Check for wall collision var mapX = Math.floor(self.worldX); var mapY = Math.floor(self.worldY); var shouldRemove = false; var hitActualMapWall = false; if (self.distance >= self.maxDistance) { shouldRemove = true; } else if (mapX < 0 || mapX >= MAP_SIZE || mapY < 0 || mapY >= MAP_SIZE) { // Projectile went out of bounds shouldRemove = true; // We could optionally treat out-of-bounds as a wall hit for particles, // but typically particles are for hitting actual geometry. } else if (map[mapY] && map[mapY][mapX] && map[mapY][mapX].type === 1) { // Projectile hit an actual map wall shouldRemove = true; hitActualMapWall = true; } if (shouldRemove) { if (hitActualMapWall) { // Spawn particle feedback at the point of impact createParticleExplosion(self.worldX, self.worldY, PROJECTILE_WALL_HIT_PARTICLE_COUNT, 0x87CEFA, true); // Light Blue Tint, isWallHit = true } return true; // Remove projectile } // Position on screen based on player view (raycasting principles) self.updateScreenPosition(); return false; // Keep projectile }; // Calculate screen position from world position self.updateScreenPosition = function () { // Vector from player to projectile var dx = self.worldX - player.x; var dy = self.worldY - player.y; // Distance from player to projectile var dist = Math.sqrt(dx * dx + dy * dy); // Angle from player to projectile var angle = Math.atan2(dy, dx) - player.dir; // Normalize angle (-PI to PI) while (angle < -Math.PI) { angle += Math.PI * 2; } while (angle > Math.PI) { angle -= Math.PI * 2; } // Check if projectile is in field of view if (Math.abs(angle) < HALF_FOV) { // Calculate screen X based on angle in FOV self.x = 2048 / 2 + angle / HALF_FOV * (2048 / 2); // Calculate target Y position (center of screen with slight adjustment) var targetY = 2732 / 2 + 40 - WALL_HEIGHT_FACTOR / dist * 0.1; // Adjusted +20 to +40 to lower target height // Calculate transition factor based on distance // As distance increases, move closer to target Y var transitionFactor = Math.min(1.0, self.distance * 1); // Interpolate between initial hand Y and target Y self.y = self.initialScreenY * (1 - transitionFactor) + targetY * transitionFactor; // Scale based on distance var scale = Math.max(0.1, 2 / dist); projectileSprite.scale.set(scale, scale); self.visible = true; } else { self.visible = false; } }; return self; }); var RaycastStrip = Container.expand(function () { var self = Container.call(this); var activeWallSprite = null; // The currently displayed wall sprite instance var currentTileIndex = null; // Stores the numeric index of activeWallSprite, e.g., 5 self.updateStrip = function (stripWidth, wallHeight, stripIdx, wallType, distance, textureX, side, tileIndex, wallMapX, wallMapY) { // self.x is now set in rayCasting for correct centering; do not set here // Validate tileIndex (already a number) if (tileIndex < 1 || tileIndex > 64 || !Number.isInteger(tileIndex)) { console.error("RaycastStrip: Invalid tileIndex received: " + tileIndex + ". Clearing strip."); self.clearStrip(); // Clear the strip if tileIndex is invalid return; } // Use direct lookup instead of string concatenation var newTileAssetName = wallTextureLookup[tileIndex]; // If the current sprite is active but not the correct type for the new tileIndex, release it. // Note: We still use currentTileIndex (numeric) for the pool key, but use newTileAssetName for LK.getAsset if needed. if (activeWallSprite && currentTileIndex !== tileIndex) { // Compare numeric indices wallSpritePoolManager.releaseSprite(activeWallSprite, currentTileIndex); // Pass numeric index self.removeChild(activeWallSprite); // Detach from this strip's container activeWallSprite = null; currentTileIndex = null; // Clear current numeric index } // If no sprite is active (either first time, or after releasing/clearing), get one from the pool. if (!activeWallSprite) { // wallSpritePoolManager.getSprite now expects the numeric tileIndex and internally uses the lookup for asset name if creating new. activeWallSprite = wallSpritePoolManager.getSprite(tileIndex); // Pass numeric index if (activeWallSprite) { self.addChild(activeWallSprite); // Add the new/reused sprite to this strip's container currentTileIndex = tileIndex; // Store numeric index } else { // This can happen if wallSpritePoolManager.getSprite returns null console.error("RaycastStrip: Failed to get sprite for tileIndex " + tileIndex + " from pool using asset name: " + newTileAssetName); self.clearStrip(); // Ensure strip is clean if sprite acquisition fails return; // Cannot render this strip without a sprite } } // Configure the active sprite's properties (position, size, appearance) activeWallSprite.width = stripWidth; activeWallSprite.height = wallHeight; // Position sprite relative to the RaycastStrip container's origin activeWallSprite.x = 0; activeWallSprite.y = (2732 - wallHeight) / 2; // Vertically center the wall segment // Apply distance-based tinting var SHADE_START_FACTOR_WALLS = 0.3; // Match floor/ceiling behavior var MIN_SHADE_WALLS = 0.2; // General minimum shade for distant walls var distanceRatio = distance / MAX_RENDER_DISTANCE; var shadeFactor = Math.max(MIN_SHADE_WALLS, 1 - distanceRatio / SHADE_START_FACTOR_WALLS); // --- Apply projectile light --- var totalProjectileLightInfluence = 0; var wallWorldCenterX = wallMapX + 0.5; var wallWorldCenterY = wallMapY + 0.5; for (var k = 0; k < projectiles.length; k++) { var proj = projectiles[k]; if (proj.active) { var dxProj = proj.worldX - wallWorldCenterX; var dyProj = proj.worldY - wallWorldCenterY; var distToProjectileSq = dxProj * dxProj + dyProj * dyProj; // Use squared distance for check if (distToProjectileSq < PROJECTILE_LIGHT_RADIUS * PROJECTILE_LIGHT_RADIUS) { var distToProjectile = Math.sqrt(distToProjectileSq); var distRatioProj = distToProjectile / PROJECTILE_LIGHT_RADIUS; var falloff = Math.pow(Math.max(0, 1 - distRatioProj), PROJECTILE_LIGHT_FALLOFF_EXPONENT); totalProjectileLightInfluence += PROJECTILE_LIGHT_MAX_CONTRIBUTION * falloff; } } } shadeFactor = Math.min(1.0, shadeFactor + totalProjectileLightInfluence); // --- End projectile light --- if (side === 1) { // Conventionally, side 1 walls (e.g., horizontal, further from light) are darker shadeFactor *= 0.7; // Apply side shadow multiplicatively // Ensure side shadow doesn't make it darker than a very low bound shadeFactor = Math.max(MIN_SHADE_WALLS * 0.5, shadeFactor); // e.g. max(0.1, shadeFactor) } // Calculate tint based on shadeFactor var baseWallTint = 0xFFFFFF; // Assuming walls are not pre-tinted by default in assets var r_wall = baseWallTint >> 16 & 0xFF; var g_wall = baseWallTint >> 8 & 0xFF; var b_wall = baseWallTint & 0xFF; r_wall = Math.floor(r_wall * shadeFactor); g_wall = Math.floor(g_wall * shadeFactor); b_wall = Math.floor(b_wall * shadeFactor); activeWallSprite.tint = r_wall << 16 | g_wall << 8 | b_wall; activeWallSprite.alpha = 1.0; // Use tint for darkness, keep alpha full for wall segments activeWallSprite.visible = true; // Ensure the sprite is visible }; // Clears the strip by releasing its active sprite back to the pool. self.clearStrip = function () { if (activeWallSprite && typeof currentTileIndex === 'number') { // Check if currentTileIndex is valid wallSpritePoolManager.releaseSprite(activeWallSprite, currentTileIndex); // Pass numeric index self.removeChild(activeWallSprite); // Detach from container activeWallSprite = null; currentTileIndex = null; } // Optionally, could make the RaycastStrip container itself invisible: // self.visible = false; // But simply having no visible children often suffices. }; // Note: LK's game reset mechanism (re-initializing Game class) typically handles // destruction of game objects. If RaycastStrip instances were managed in a way // that required manual cleanup of their sprites before game reset, a specific // destroy method could be added here. For now, clearStrip and parent destruction // should manage pooled sprites correctly. return self; }); var Treasure = Container.expand(function () { var self = Container.call(this); // Closed treasure sprite var treasureSprite = self.attachAsset('treasure', { anchorX: 0.5, anchorY: 0.5 }); treasureSprite.tint = 0xdddddd; // Light grey tint // Open treasure sprite (initially hidden) var treasureOpenSprite = self.attachAsset('treasureopen', { anchorX: 0.5, anchorY: 0.5, alpha: 0 }); self.treasureSprite = treasureSprite; self.treasureOpenSprite = treasureOpenSprite; self.isOpen = false; self.mapX = 0; self.mapY = 0; self.value = 1; self.openTreasure = function () { if (!self.isOpen) { self.isOpen = true; self.treasureSprite.alpha = 0; self.treasureOpenSprite.alpha = 1; } }; return self; }); /**** * Initialize Game ****/ var game = new LK.Game({ backgroundColor: 0x111111 }); /**** * Game Code ****/ var dynamicEntitiesContainer; // At start of game, create a texture lookup table var wallTextureLookup = new Array(65); for (var i = 1; i <= 64; i++) { wallTextureLookup[i] = 'walltile' + i; } var wallSpritePoolManager = { pools: function () { // IIFE to pre-populate pools for each wall tile type, using numeric keys. var p = {}; var PREALLOCATE_PER_TILE = 48; // Increased from 12 to reduce pop-in for (var i = 1; i <= 64; i++) { // Numeric index for pool key var assetNameForLK = wallTextureLookup[i]; // Use lookup table for asset name if (!assetNameForLK) { console.error("WallSpritePoolManager (init pools): No asset name found in lookup for tileIndex: " + i); continue; // Skip if asset name is not found } var tilePool = []; // Cache the array for this specific tile index p[i] = tilePool; // Use numeric index i as the key for (var j = 0; j < PREALLOCATE_PER_TILE; j++) { var sprite = LK.getAsset(assetNameForLK, { // Use string name from lookup for LK anchorX: 0, anchorY: 0, visible: false }); tilePool.push(sprite); // Push to the cached array } } return p; }(), getSprite: function getSprite(tileIndex) { // Parameter is now tileIndex (number) // Validate tileIndex and ensure its pool exists if (!this.pools[tileIndex]) { var isValidTile = Number.isInteger(tileIndex) && tileIndex >= 1 && tileIndex <= 64; if (isValidTile) { this.pools[tileIndex] = []; // Create pool if valid but missing console.warn("WallSpritePoolManager: Created pool on-the-fly for tile index: " + tileIndex); } else { console.error("WallSpritePoolManager: Attempted to get sprite for invalid tile index: " + tileIndex); return null; // Invalid tile index } } var pool = this.pools[tileIndex]; if (pool.length > 0) { var sprite = pool.pop(); return sprite; } else { // Pool is empty, create a new sprite on demand // Use the lookup table to get the asset name string var assetNameForLK = wallTextureLookup[tileIndex]; if (!assetNameForLK) { console.error("WallSpritePoolManager: No asset name found in lookup for tileIndex: " + tileIndex); return null; } var newSprite = LK.getAsset(assetNameForLK, { anchorX: 0, anchorY: 0, visible: false }); return newSprite; } }, releaseSprite: function releaseSprite(sprite, tileIndex) { // Parameter is now tileIndex (number) if (!sprite || typeof tileIndex !== 'number') { console.error("WallSpritePoolManager: Invalid sprite or tileIndex for release. tileIndex: " + tileIndex); return; } // Validate tileIndex and ensure its pool exists for release if (!this.pools[tileIndex]) { var isValidTile = Number.isInteger(tileIndex) && tileIndex >= 1 && tileIndex <= 64; if (isValidTile) { this.pools[tileIndex] = []; // Create pool if valid but missing console.warn("WallSpritePoolManager: Releasing sprite to a newly created pool for tile index: " + tileIndex); } else { console.error("WallSpritePoolManager: Attempted to release sprite for invalid tile index: " + tileIndex); return; } } // Sprite is assumed to be already removed from its parent by RaycastStrip. // Thoroughly reset sprite properties to a clean state: sprite.visible = false; sprite.alpha = 1.0; sprite.scale.set(1.0, 1.0); sprite.rotation = 0; sprite.x = 0; sprite.y = 0; sprite.tint = 0xFFFFFF; // Reset tint as it's now being used for shading this.pools[tileIndex].push(sprite); } }; // Game constants var PROJECTILE_LIGHT_RADIUS = 5.0; // World units for how far projectile light reaches var PROJECTILE_LIGHT_MAX_CONTRIBUTION = 0.6; // Max brightness projectile can add (0 to 1) var PROJECTILE_LIGHT_FALLOFF_EXPONENT = 2.0; // Power for light falloff (e.g., 2 for quadratic) var MAP_SIZE = 16; var PROJECTILE_WALL_HIT_PARTICLE_COUNT = 8; // Number of particles for projectile wall impact var particlePoolManager; var particleExplosionContainer; // Container for all active particles from explosions var CELL_SIZE = 20; var MINI_MAP_SCALE = 1; var STRIP_WIDTH = 5; // Increase from 8 to 16 (half as many rays) var NUM_RAYS = Math.ceil(2048 / STRIP_WIDTH); // Pre-allocate ray result objects to avoid garbage collection var rayResults = new Array(NUM_RAYS); for (var i = 0; i < NUM_RAYS; i++) { rayResults[i] = { wallHit: false, distance: 0, // perpWallDist from DDA actualDistance: 0, // distance corrected for FOV, used for shading and strip height side: 0, wallType: 0, mapX: 0, // from DDA mapY: 0, // from DDA screenX: 0, // strip's screen X position wallHeight: 0, // calculated wall height textureIndex: 0 // calculated texture/tile index }; } var FOV = Math.PI / 3; // 60 degrees field of view // Variables for raycasting optimization var lastPlayerX = -999; var lastPlayerY = -999; var lastPlayerDir = -999; var shouldRaycast = true; // Ensure first frame does a full raycast var HALF_FOV = FOV / 2; var PLAYER_MOVE_SPEED = 0.002; // Reduced from 0.005 to make movement slower var PLAYER_TURN_SPEED = 0.002; // Reduced from 0.005 to make turning slower var WALL_HEIGHT_FACTOR = 700; var MAX_RENDER_DISTANCE = 16; var MONSTER_AGGRO_RANGE = 7.0; // Max distance at which monsters will notice and start chasing the player. var MONSTER_CLOSE_PROXIMITY_RANGE = 4.0; // Max distance for non-LOS chase if within AGGRO_RANGE. var MIN_MONSTER_PLAYER_DISTANCE = 0.6; // Monsters stop if they get closer than this (e.g., overshoot); this is within attack range. var MAX_MONSTER_PLAYER_ENGAGEMENT_DISTANCE = 0.6; var WALL_BUFFER = 0.18; // Minimum distance from player center to wall cell's effective edge var MONSTER_WALL_BUFFER = 0.25; // Minimum distance from monster center to wall cell's effective edge var MONSTER_COUNT = 8; // Increased by 50% from 5, rounded up var TREASURE_COUNT = 10; // Game state var map = []; var floorCaster; var player = { x: 1.5, y: 1.5, dir: 0, health: 5, // Current health maxHealth: 5, // Maximum health, starts at 5 score: 0, level: 1, // Permanent Stats Enhanced by Power-ups moveSpeedMultiplier: 1.0, // Base is 1.0, increases with speed power-ups defenseBuffActive: false, // Remains a boolean toggle, activated by defense power-up attackPower: 1 // Base attack power is 1, increases with attack power-ups // Buff timeouts are removed as effects are permanent }; var powerUps = []; var POWERUP_COUNT = 3; // Number of power-ups to spawn per level // POWERUP_DURATION is no longer needed as buffs are permanent until new game var controls = { forward: false, backward: false, left: false, right: false, attack: false }; var monsters = []; var treasures = []; var fountains = []; var fountainPlacedThisLevel = false; var projectiles = []; var gate = null; var wallSegments = []; var lastWallCheck = []; var lastTime = Date.now(); var canAttack = true; var attackCooldown = 500; // 500 millisecond cooldown // Joystick override state var joystickOverrideActive = false; var joystickOverrideX = 0; var joystickOverrideY = 0; // UI elements var miniMap; var rayCastView; var healthText; var scoreText; var levelText; var monsterText; var controlButtons = {}; var playerMarker; var globalRightHand; // Stores the right hand game object var activeControlForGlobalHandlers = null; // Tracks which control is active for game.down/up/move var renderableObjects = []; // Array to hold all objects (walls, entities) to be sorted by depth // Create layer variables at the global scope var gameLayer = new Container(); var projectileLayer = new Container(); var handLayer = new Container(); particlePoolManager = { pool: [], activeParticles: [], maxParticles: 750, // Increased from 300 to support more particles // Increased max particles getParticle: function getParticle() { var particle; if (this.pool.length > 0) { particle = this.pool.pop(); } else { particle = new Particle(); } this.activeParticles.push(particle); // The particle will be added to particleExplosionContainer by createParticleExplosion return particle; }, releaseParticle: function releaseParticle(particle) { // Remove from activeParticles var index = this.activeParticles.indexOf(particle); if (index !== -1) { this.activeParticles.splice(index, 1); } // Remove from its parent container (particleExplosionContainer) if (particle.parent) { particle.parent.removeChild(particle); } if (this.pool.length < this.maxParticles) { this.pool.push(particle); } else { // If pool is full, LK will garbage collect if it has no parent. // Explicitly calling destroy isn't standard in LK for pooled objects unless necessary. } }, updateActiveParticles: function updateActiveParticles() { for (var i = this.activeParticles.length - 1; i >= 0; i--) { var particle = this.activeParticles[i]; if (!particle.update()) { // Particle.update returns false when life is over this.releaseParticle(particle); } } } }; function createParticleExplosion(worldExplosionX, worldExplosionY, count, tintColor, isWallHit) { if (!particleExplosionContainer) { particleExplosionContainer = new Container(); projectileLayer.addChild(particleExplosionContainer); } var finalExplosionX = worldExplosionX; var finalExplosionY = worldExplosionY; var wallNormalX = 0; var wallNormalY = 0; var applyWallBias = isWallHit === true; // Explicitly check for true if (applyWallBias) { // Determine the wall normal direction to offset the particles away from the wall var cellX = Math.floor(worldExplosionX); var cellY = Math.floor(worldExplosionY); // Check adjacent cells to determine wall direction if (cellX + 1 < MAP_SIZE && map[cellY][cellX + 1].type === 1) { wallNormalX = -1; // Wall is to the right, push left } else if (cellX - 1 >= 0 && map[cellY][cellX - 1].type === 1) { wallNormalX = 1; // Wall is to the left, push right } if (cellY + 1 < MAP_SIZE && map[cellY + 1][cellX].type === 1) { wallNormalY = -1; // Wall is below, push up } else if (cellY - 1 >= 0 && map[cellY - 1][cellX].type === 1) { wallNormalY = 1; // Wall is above, push down } // If we're at a corner or couldn't determine direction, use distance from cell center if (wallNormalX === 0 && wallNormalY === 0) { wallNormalX = worldExplosionX - (cellX + 0.5); wallNormalY = worldExplosionY - (cellY + 0.5); var normalLength = Math.sqrt(wallNormalX * wallNormalX + wallNormalY * wallNormalY); if (normalLength > 0) { wallNormalX /= normalLength; wallNormalY /= normalLength; } else { wallNormalX = -Math.cos(player.dir); wallNormalY = -Math.sin(player.dir); } } var offsetDistance = 0.1; finalExplosionX = worldExplosionX + wallNormalX * offsetDistance; finalExplosionY = worldExplosionY + wallNormalY * offsetDistance; } // else, finalExplosionX/Y remain worldExplosionX/Y var worldExplosionZ = 0.5; // Assume explosion originates roughly half a unit above the floor for (var i = 0; i < count; i++) { var particle = particlePoolManager.getParticle(); var phi = Math.random() * Math.PI * 2; var theta = Math.acos(Math.random() * 2 - 1); var speed = (Math.random() * 0.015 + 0.0075) * (player.level > 3 ? 1.5 : 1); var vx = Math.sin(theta) * Math.cos(phi) * speed; var vy = Math.sin(theta) * Math.sin(phi) * speed; var vz = Math.cos(theta) * speed + 0.03; if (applyWallBias) { vx += wallNormalX * speed * 0.5; vy += wallNormalY * speed * 0.5; } var life = 360 + Math.random() * 240; particle.init(finalExplosionX, finalExplosionY, worldExplosionZ, vx, vy, vz, life, tintColor); particle.collisionImmunity = applyWallBias ? 10 : 0; // Only give immunity for wall hits particleExplosionContainer.addChild(particle); } } // Setup game function setupGame() { // Set up the layer system using the globally defined variables // Add layers in the correct order (projectiles below hand) game.addChild(gameLayer); game.addChild(projectileLayer); game.addChild(handLayer); // Create the floor/ceiling caster first (should be below walls) floorCaster = new FloorCaster(); gameLayer.addChild(floorCaster); // Create the rayCast view container // rayCastView = new Container(); // rayCastView is no longer used to hold strips directly // gameLayer.addChild(rayCastView); // No longer add rayCastView if it's not used for strips // Initialize wallSegments array to hold RaycastStrip instances wallSegments = []; // Create raycast strips for (var i = 0; i < NUM_RAYS; i++) { var strip = new RaycastStrip(); wallSegments.push(strip); // Store strips in an array, not in a display container yet } // Initialize container for depth-sorted dynamic entities (walls and entities) dynamicEntitiesContainer = new Container(); gameLayer.addChild(dynamicEntitiesContainer); // Create minimap container miniMap = new Container(); // Move mini map left by 25% of its original centered position miniMap.x = (2048 - MAP_SIZE * CELL_SIZE * MINI_MAP_SCALE) / 2 * 0.40; miniMap.y = 40; // Keep at top gameLayer.addChild(miniMap); // Generate map generateMap(); // Create player marker playerMarker = gameLayer.addChild(LK.getAsset('player', { anchorX: 0.5, anchorY: 0.5 })); // Create UI elements createUI(); // Create control buttons createControlButtons(); // Start background music LK.playMusic('dungeon'); } function generateMap() { // Clear existing map visuals, data structures miniMap.removeChildren(); map = []; // Remove existing monsters and treasures for (var i = 0; i < monsters.length; i++) { monsters[i].destroy(); } monsters = []; for (var i = 0; i < treasures.length; i++) { treasures[i].destroy(); } treasures = []; // Clear projectiles for (var i = 0; i < projectiles.length; i++) { projectiles[i].destroy(); } projectiles = []; // Clear existing powerups for (var i = 0; i < powerUps.length; i++) { powerUps[i].destroy(); } powerUps = []; // Clear existing fountains for (var i = 0; i < fountains.length; i++) { if (fountains[i].parent) { fountains[i].parent.removeChild(fountains[i]); } fountains[i].destroy(); } fountains = []; fountainPlacedThisLevel = false; // Reset flag for the new level // --- Map type selection --- // 0 = maze, 1 = arena with pillars, 2 = mixed, 3 = two-rooms-grid var mapType = Math.floor(Math.random() * 4); // 1. Initialize the map: all cells are walls for (var y = 0; y < MAP_SIZE; y++) { map[y] = []; for (var x = 0; x < MAP_SIZE; x++) { var cell = new MapCell(); cell.x = x * CELL_SIZE * MINI_MAP_SCALE; cell.y = y * CELL_SIZE * MINI_MAP_SCALE; cell.setType(1); // Initialize as wall map[y][x] = cell; miniMap.addChild(cell); // Add to minimap } } // --- Maze type --- if (mapType === 0) { // 2. Implement Recursive Backtracker (a form of Growing Tree) for maze generation var stack = []; var startX = 1; // Player's typical start X var startY = 1; // Player's typical start Y map[startY][startX].setType(0); // Mark starting cell as floor stack.push({ x: startX, y: startY }); while (stack.length > 0) { var current = stack[stack.length - 1]; // Get current cell (peek) var potentialMoves = [{ dx: 0, dy: -2, wallXOffset: 0, wallYOffset: -1 }, { dx: 2, dy: 0, wallXOffset: 1, wallYOffset: 0 }, { dx: 0, dy: 2, wallXOffset: 0, wallYOffset: 1 }, { dx: -2, dy: 0, wallXOffset: -1, wallYOffset: 0 }]; // Shuffle potential moves to ensure randomness for (var i = potentialMoves.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var temp = potentialMoves[i]; potentialMoves[i] = potentialMoves[j]; potentialMoves[j] = temp; } var moved = false; for (var i = 0; i < potentialMoves.length; i++) { var move = potentialMoves[i]; var nextX = current.x + move.dx; var nextY = current.y + move.dy; var wallBetweenX = current.x + move.wallXOffset; var wallBetweenY = current.y + move.wallYOffset; if (nextX >= 0 && nextX < MAP_SIZE && nextY >= 0 && nextY < MAP_SIZE && map[nextY][nextX].type === 1) { map[wallBetweenY][wallBetweenX].setType(0); map[nextY][nextX].setType(0); stack.push({ x: nextX, y: nextY }); moved = true; break; } } if (!moved) { stack.pop(); } } // 3. Explicitly set outer border walls. for (y = 0; y < MAP_SIZE; y++) { if (map[y][0].type === 0 && !(y === startY && startX === 0)) { map[y][0].setType(1); } if (map[y][MAP_SIZE - 1].type === 0 && !(y === startY && startX === MAP_SIZE - 1)) { map[y][MAP_SIZE - 1].setType(1); } } for (x = 0; x < MAP_SIZE; x++) { if (map[0][x].type === 0 && !(x === startX && startY === 0)) { map[0][x].setType(1); } if (map[MAP_SIZE - 1][x].type === 0 && !(x === startX && startY === MAP_SIZE - 1)) { map[MAP_SIZE - 1][x].setType(1); } } map[startY][startX].setType(0); ensureMapConnectivity(); } else if (mapType === 1) { // --- Arena with pillars --- // Clear a large open area in the center var margin = 2; for (var y = margin; y < MAP_SIZE - margin; y++) { for (var x = margin; x < MAP_SIZE - margin; x++) { map[y][x].setType(0); } } // Place pillars in a grid pattern for (var py = margin + 2; py < MAP_SIZE - margin - 1; py += 3) { for (var px = margin + 2; px < MAP_SIZE - margin - 1; px += 3) { map[py][px].setType(1); } } // Player start near bottom left // player.x = margin + 1.5; // Player position will be set later // player.y = margin + 1.5; // Player position will be set later // player.dir = 0;//{8K} // Player position will be set later } else if (mapType === 3) { // --- Grid of corridors with a small room in the center --- var gridSpacing = 3; // Defines the spacing of the corridors // Create horizontal corridors for (var gy = 1; gy < MAP_SIZE - 1; gy++) { if (gy % gridSpacing === 1) { // Place a corridor if gy is 1, 4, 7... (for gridSpacing = 3) for (var x = 1; x < MAP_SIZE - 1; x++) { map[gy][x].setType(0); } } } // Create vertical corridors for (var gx = 1; gx < MAP_SIZE - 1; gx++) { if (gx % gridSpacing === 1) { // Place a corridor if gx is 1, 4, 7... (for gridSpacing = 3) for (var y = 1; y < MAP_SIZE - 1; y++) { map[y][gx].setType(0); } } } // Create a small central room var centralRoomSize = 3; // Size of the central room (e.g., 3x3) var roomStartX = Math.floor(MAP_SIZE / 2) - Math.floor(centralRoomSize / 2); var roomStartY = Math.floor(MAP_SIZE / 2) - Math.floor(centralRoomSize / 2); for (var y_room = roomStartY; y_room < roomStartY + centralRoomSize; y_room++) { for (var x_room = roomStartX; x_room < roomStartX + centralRoomSize; x_room++) { // Ensure room coordinates are within the carveable map area if (x_room >= 1 && x_room < MAP_SIZE - 1 && y_room >= 1 && y_room < MAP_SIZE - 1) { map[y_room][x_room].setType(0); } } } // 4. Ensure all parts of the map are connected after generation (for all types) ensureMapConnectivity(); } else { // --- Mixed: maze with open arena center and some pillars --- // First, generate a maze as in type 0 var stack = []; var startX = 1; var startY = 1; map[startY][startX].setType(0); stack.push({ x: startX, y: startY }); while (stack.length > 0) { var current = stack[stack.length - 1]; var potentialMoves = [{ dx: 0, dy: -2, wallXOffset: 0, wallYOffset: -1 }, { dx: 2, dy: 0, wallXOffset: 1, wallYOffset: 0 }, { dx: 0, dy: 2, wallXOffset: 0, wallYOffset: 1 }, { dx: -2, dy: 0, wallXOffset: -1, wallYOffset: 0 }]; for (var i = potentialMoves.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var temp = potentialMoves[i]; potentialMoves[i] = potentialMoves[j]; potentialMoves[j] = temp; } var moved = false; for (var i = 0; i < potentialMoves.length; i++) { var move = potentialMoves[i]; var nextX = current.x + move.dx; var nextY = current.y + move.dy; var wallBetweenX = current.x + move.wallXOffset; var wallBetweenY = current.y + move.wallYOffset; if (nextX >= 0 && nextX < MAP_SIZE && nextY >= 0 && nextY < MAP_SIZE && map[nextY][nextX].type === 1) { map[wallBetweenY][wallBetweenX].setType(0); map[nextY][nextX].setType(0); stack.push({ x: nextX, y: nextY }); moved = true; break; } } if (!moved) { stack.pop(); } } // Open up a central arena var arenaMargin = 4; for (var y = arenaMargin; y < MAP_SIZE - arenaMargin; y++) { for (var x = arenaMargin; x < MAP_SIZE - arenaMargin; x++) { map[y][x].setType(0); } } // Add a few random pillars in the arena for (var p = 0; p < 8; p++) { var px = Math.floor(Math.random() * (MAP_SIZE - 2 * arenaMargin - 2)) + arenaMargin + 1; var py = Math.floor(Math.random() * (MAP_SIZE - 2 * arenaMargin - 2)) + arenaMargin + 1; map[py][px].setType(1); } // Player start near bottom left // player.x = arenaMargin + 1.5; // Player position will be set later // player.y = arenaMargin + 1.5; // Player position will be set later // player.dir = 0;//{9M} // Player position will be set later } // 4. Ensure all parts of the map are connected after generation (for all types) ensureMapConnectivity(); // --- Player Spawn Logic --- var intendedPlayerX, intendedPlayerY; if (mapType === 0) { // Maze intendedPlayerX = 1.5; intendedPlayerY = 1.5; } else if (mapType === 1) { // Arena var margin = 2; // Variable 'margin' is defined in the Arena mapType block intendedPlayerX = margin + 1.5; intendedPlayerY = margin + 1.5; } else if (mapType === 2) { // Explicitly mapType === 2 for Mixed // Mixed var arenaMargin = 4; // Variable 'arenaMargin' is defined in the Mixed mapType block intendedPlayerX = arenaMargin + 1.5; intendedPlayerY = arenaMargin + 1.5; } else if (mapType === 3) { // New case for mapType === 3 // Grid with central room - spawn in the center of the map (likely in the central room) intendedPlayerX = Math.floor(MAP_SIZE / 2) + 0.5; intendedPlayerY = Math.floor(MAP_SIZE / 2) + 0.5; } var spawnIsValid = false; // Check if intendedPlayerX/Y are within map cell indices for array access var intendedCellX = Math.floor(intendedPlayerX); var intendedCellY = Math.floor(intendedPlayerY); if (intendedCellX >= 0 && intendedCellX < MAP_SIZE && intendedCellY >= 0 && intendedCellY < MAP_SIZE) { if (map[intendedCellY][intendedCellX].type === 0 && getDistanceToNearestWall(intendedPlayerX, intendedPlayerY) >= WALL_BUFFER) { spawnIsValid = true; player.x = intendedPlayerX; player.y = intendedPlayerY; } } if (!spawnIsValid) { console.warn("Intended player spawn point was invalid. Searching for an alternative..."); var alternativeSpawns = []; // Search the entire map, excluding borders, for a valid spawn point for (var y_scan = 1; y_scan < MAP_SIZE - 1; y_scan++) { for (var x_scan = 1; x_scan < MAP_SIZE - 1; x_scan++) { var currentSpawnCandidateX = x_scan + 0.5; var currentSpawnCandidateY = y_scan + 0.5; if (map[y_scan][x_scan].type === 0 && getDistanceToNearestWall(currentSpawnCandidateX, currentSpawnCandidateY) >= WALL_BUFFER) { alternativeSpawns.push({ x: currentSpawnCandidateX, y: currentSpawnCandidateY }); } } } if (alternativeSpawns.length > 0) { var randomIndex = Math.floor(Math.random() * alternativeSpawns.length); player.x = alternativeSpawns[randomIndex].x; player.y = alternativeSpawns[randomIndex].y; console.log("Player spawn adjusted to a valid alternative: (" + player.x + ", " + player.y + ")"); } else { // This is a critical failure case - no valid spawn point found anywhere. // Default to a common, possibly unsafe, point and log an error. console.error("CRITICAL: No valid player spawn point found anywhere in the map. Defaulting to (1.5, 1.5). Player may be stuck."); player.x = 1.5; player.y = 1.5; } } player.dir = 0; // Ensure player direction is reset // Ensure player doesn't start facing a wall var initialPlayerDirForRotationCheck = player.dir; // Store initial to potentially revert var playerRotationAttempts = 0; while (playerRotationAttempts < 4) { // Max 4 attempts for 0, 90, 180, 270 degrees var lookAheadDist = 1.0; // Check one cell ahead var cellInFrontMapX = Math.floor(player.x + Math.cos(player.dir) * lookAheadDist); var cellInFrontMapY = Math.floor(player.y + Math.sin(player.dir) * lookAheadDist); // Check if the cell in front is within map boundaries and is a floor tile if (cellInFrontMapX >= 0 && cellInFrontMapX < MAP_SIZE && cellInFrontMapY >= 0 && cellInFrontMapY < MAP_SIZE && map[cellInFrontMapY] && map[cellInFrontMapY][cellInFrontMapX] && map[cellInFrontMapY][cellInFrontMapX].type === 0) { // Found an open space to face break; } // If facing a wall or out of bounds, rotate player by 90 degrees clockwise player.dir += Math.PI / 2; // Normalize direction to be within [0, 2*PI) if (player.dir >= Math.PI * 2) { player.dir -= Math.PI * 2; } playerRotationAttempts++; } if (playerRotationAttempts === 4) { console.warn("Player could not be rotated to face an open space after 4 attempts. Reverting to original direction: " + initialPlayerDirForRotationCheck); player.dir = initialPlayerDirForRotationCheck; // A more robust fallback could be to find ANY open adjacent cell and face it, // but for now, reverting or keeping the last attempted direction is acceptable. } // --- End Player Spawn Logic --- // Create monsters var monstersToPlace = MONSTER_COUNT + Math.floor(player.level * 0.5); for (var i = 0; i < monstersToPlace; i++) { placeMonster(); } // Create treasures placeTreasure(); // Call once to place 1-3 treasures in total per map // Place exit gate gate = placeGate(); // Place one fountain placeFountain(); // Power-ups are now only spawned from opened treasures, not during map generation } function placePowerUp() { var x, y; var attempts = 0; var powerUpTypes = ['speed', 'defense', 'health', 'attack']; var randomType = powerUpTypes[Math.floor(Math.random() * powerUpTypes.length)]; do { x = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1; y = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1; attempts++; if (attempts > 100) { break; } } while (map[y][x].type !== 0 || map[y][x].monster || map[y][x].treasure || map[y][x].gate || mapCellHasPowerUp(x, y)); if (attempts <= 100) { var powerUp = new PowerUp(randomType); powerUp.mapX = x + 0.5; // Center in cell powerUp.mapY = y + 0.5; // Center in cell powerUps.push(powerUp); dynamicEntitiesContainer.addChild(powerUp); } } function placeFountain() { if (fountainPlacedThisLevel) { return; } var potentialSpots = []; var deadEndSpots = []; for (var y = 1; y < MAP_SIZE - 1; y++) { for (var x = 1; x < MAP_SIZE - 1; x++) { if (map[y] && map[y][x] && map[y][x].type === 0 && !map[y][x].monster && !map[y][x].treasure && !map[y][x].gate) { // Check if already a fountain here by iterating fountains array var alreadyFountain = false; for (var fIdx = 0; fIdx < fountains.length; fIdx++) { if (Math.floor(fountains[fIdx].mapX) === x && Math.floor(fountains[fIdx].mapY) === y) { alreadyFountain = true; break; } } if (!alreadyFountain) { potentialSpots.push({ x: x, y: y }); } } } } if (potentialSpots.length === 0) { return; } for (var i = 0; i < potentialSpots.length; i++) { var spot = potentialSpots[i]; var openNeighbors = 0; var neighbors = [{ dx: 0, dy: -1 }, { dx: 1, dy: 0 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }]; for (var j = 0; j < neighbors.length; j++) { var nx = spot.x + neighbors[j].dx; var ny = spot.y + neighbors[j].dy; if (nx >= 0 && nx < MAP_SIZE && ny >= 0 && ny < MAP_SIZE && map[ny] && map[ny][nx] && map[ny][nx].type === 0) { openNeighbors++; } } if (openNeighbors === 1) { deadEndSpots.push(spot); } } if (deadEndSpots.length === 0) { return; } // Shuffle deadEndSpots for (var i = deadEndSpots.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var temp = deadEndSpots[i]; deadEndSpots[i] = deadEndSpots[j]; deadEndSpots[j] = temp; } var placed = false; for (var k = 0; k < deadEndSpots.length; k++) { var spotToPlace = deadEndSpots[k]; var cellX = spotToPlace.x; var cellY = spotToPlace.y; var isPlayerSpot = cellX === Math.floor(player.x) && cellY === Math.floor(player.y); // Gate might not be placed yet if this is called before placeGate, so check gate object directly var isGateSpot = gate && cellX === Math.floor(gate.mapX) && cellY === Math.floor(gate.mapY); // Re-check map cell properties directly, as they are the source of truth for monsters/treasures placed by their respective functions var cellIsOccupiedByOtherMapEntity = map[cellY][cellX].monster || map[cellY][cellX].treasure || map[cellY][cellX].gate; if (!isPlayerSpot && !isGateSpot && !cellIsOccupiedByOtherMapEntity && map[cellY][cellX].type === 0) { var fountain = new Fountain(); fountain.init(cellX + 0.5, cellY + 0.5); fountains.push(fountain); dynamicEntitiesContainer.addChild(fountain); // Add to the same container as monsters/treasures fountainPlacedThisLevel = true; placed = true; break; } } } function mapCellHasPowerUp(cellX, cellY) { for (var i = 0; i < powerUps.length; i++) { if (Math.floor(powerUps[i].mapX) === cellX && Math.floor(powerUps[i].mapY) === cellY) { return true; } } return false; } function placeMonster() { // Find a random empty cell var x, y; var attempts = 0; do { x = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1; y = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1; attempts++; // Make sure it's not too close to the player var distToPlayer = Math.sqrt(Math.pow(x - player.x, 2) + Math.pow(y - player.y, 2)); if (attempts > 100) { break; } // Prevent infinite loop } while (map[y][x].type !== 0 || map[y][x].monster || map[y][x].treasure || distToPlayer < 3); if (attempts <= 100) { map[y][x].addMonster(); var monster; // Randomly decide to spawn a regular Monster, an EyeballMonster, or an ImpMonster var randMonsterType = Math.random(); if (randMonsterType < 0.1) { // 50% chance for Ogre (Monster) monster = new Monster(); // Monster health is set in its constructor } else if (randMonsterType < 0.4) { // 30% chance for EyeballMonster (0.5 to 0.799...) monster = new EyeballMonster(); // EyeballMonster health is set in its constructor // monster.startBobbing(); // Call removed as bobbing is disabled } else { // 20% chance for ImpMonster (0.8 to 0.999...) monster = new ImpMonster(); // ImpMonster health and other stats are set in its constructor } // Common monster setup monster.mapX = x + 0.5; // Center in cell monster.mapY = y + 0.5; // Center in cell // Initialize attack cooldown properties for all monster types // These might be defined in individual monster classes but ensuring they exist for MonsterAI if (monster.attackCooldown === undefined) { monster.attackCooldown = 2000; } // Default if not set if (monster.lastAttackTime === undefined) { monster.lastAttackTime = 0; } if (monster.canAttack === undefined) { monster.canAttack = true; } monsters.push(monster); dynamicEntitiesContainer.addChild(monster); } } function placeTreasure() { var potentialSpots = []; var deadEndSpots = []; // Iterate through the map (excluding borders initially for simplicity in dead-end logic) // to find all valid floor cells for treasure. for (var y = 1; y < MAP_SIZE - 1; y++) { for (var x = 1; x < MAP_SIZE - 1; x++) { if (map[y] && map[y][x] && map[y][x].type === 0 && !map[y][x].monster && !map[y][x].treasure && // Check if cell already has a treasure (though addTreasure also checks) !map[y][x].gate) { potentialSpots.push({ x: x, y: y }); } } } if (potentialSpots.length === 0) { return; // No suitable spots found } // Identify dead ends from the list of potential spots for (var i = 0; i < potentialSpots.length; i++) { var spot = potentialSpots[i]; var openNeighbors = 0; var neighbors = [{ dx: 0, dy: -1 }, // North { dx: 1, dy: 0 }, // East { dx: 0, dy: 1 }, // South { dx: -1, dy: 0 } // West ]; for (var j = 0; j < neighbors.length; j++) { var nx = spot.x + neighbors[j].dx; var ny = spot.y + neighbors[j].dy; // Check bounds and if neighbor is a floor cell if (nx >= 0 && nx < MAP_SIZE && ny >= 0 && ny < MAP_SIZE && map[ny] && map[ny][nx] && map[ny][nx].type === 0) { openNeighbors++; } } if (openNeighbors === 1) { deadEndSpots.push(spot); } } // Only spawn treasures in dead ends, and limit to 1-3 per map (total) var maxTreasures = 1 + Math.floor(Math.random() * 3); // 1 to 3 treasures if (deadEndSpots.length === 0) { return; // No dead ends found, do not spawn treasure } if (deadEndSpots.length > 1) { // Shuffle deadEndSpots for (var i = deadEndSpots.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var temp = deadEndSpots[i]; deadEndSpots[i] = deadEndSpots[j]; deadEndSpots[j] = temp; } } var treasuresPlaced = 0; for (var i = 0; i < deadEndSpots.length && treasuresPlaced < maxTreasures; i++) { var spot = deadEndSpots[i]; var cellX = spot.x; var cellY = spot.y; // Check if the spot is the player's current location if (cellX === Math.floor(player.x) && cellY === Math.floor(player.y)) { continue; // Skip this spot if it's where the player is } // Double-check if we can add treasure to this cell (MapCell's own logic) if (map[cellY] && map[cellY][cellX] && map[cellY][cellX].addTreasure()) { var treasure = new Treasure(); treasure.mapX = cellX + 0.5; // Center treasure in the cell treasure.mapY = cellY + 0.5; // Center treasure in the cell treasure.value = 1 + Math.floor(Math.random() * player.level); treasures.push(treasure); dynamicEntitiesContainer.addChild(treasure); treasuresPlaced++; } } } function placeGate() { // Place gate in a random valid location var x, y; var attempts = 0; var validPositions = []; // Collect all valid positions first for (var i = 0; i < 100; i++) { x = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1; y = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1; // Check if the cell is suitable if (map[y][x].type === 0 && !map[y][x].monster && !map[y][x].treasure && !map[y][x].gate) { // Make sure it's not too close to the player (at least 2 cells away) var distToPlayer = Math.sqrt(Math.pow(x - player.x, 2) + Math.pow(y - player.y, 2)); if (distToPlayer > 2) { // Add to valid positions validPositions.push({ x: x, y: y }); } } } // If we found valid spots, choose one randomly if (validPositions.length > 0) { // Pick a random position from the valid ones var randomIndex = Math.floor(Math.random() * validPositions.length); var chosenPos = validPositions[randomIndex]; var gateX = chosenPos.x; var gateY = chosenPos.y; // Place the gate map[gateY][gateX].addGate(); var gate = new Gate(); gate.mapX = gateX; gate.mapY = gateY; dynamicEntitiesContainer.addChild(gate); return gate; } return null; } function createUI() { // Health display healthText = new Text2('Health: ' + player.health, { size: 40, fill: 0xFF5555 }); healthText.anchor.set(0, 0); LK.gui.topRight.addChild(healthText); healthText.x = -200; healthText.y = 20; // Score display scoreText = new Text2('Score: ' + player.score, { size: 40, fill: 0xFFFF55 }); scoreText.anchor.set(0, 0); LK.gui.topRight.addChild(scoreText); scoreText.x = -200; scoreText.y = 80; // Level display levelText = new Text2('Level: ' + player.level, { size: 40, fill: 0x55FF55 }); levelText.anchor.set(0, 0); LK.gui.topRight.addChild(levelText); levelText.x = -200; levelText.y = 140; // Monster counter display monsterText = new Text2('Monsters: 0', { size: 40, fill: 0xFF9955 }); monsterText.anchor.set(0, 0); LK.gui.topRight.addChild(monsterText); monsterText.x = -200; monsterText.y = 200; // Update UI displays updateUI(); } function updateUI() { healthText.setText('Health: ' + player.health); scoreText.setText('Score: ' + player.score); levelText.setText('Level: ' + player.level); monsterText.setText('Monsters: ' + monsters.length); // Update score in LK system LK.setScore(player.score); } function createControlButtons() { // Create joystick control for movement controlButtons.joystick = new JoystickController(); controlButtons.joystick.x = 400; controlButtons.joystick.y = 2732 - 500; gameLayer.addChild(controlButtons.joystick); // Using the global layer system - no need to recreate layers here // Create attack button more centered on the right side controlButtons.attack = new ControlButton('attack'); controlButtons.attack.x = 2048 - 400; controlButtons.attack.y = 2732 - 500; gameLayer.addChild(controlButtons.attack); // Add right hand at the bottom right for projectile firing globalRightHand = LK.getAsset('rightHand', { anchorX: 0.5, anchorY: 0.9, scaleX: 1.2, scaleY: 1.2 }); globalRightHand.x = 2048 * 0.6; // Position just off right of middle globalRightHand.y = 2732; // Bottom of the screen handLayer.addChild(globalRightHand); // Add pulse and float animation to give the hand some life var _animateHand = function animateHand() { // Get a small random x offset between -40 and 40 pixels var randomXOffset = Math.random() * 80 - 40; // Store original X position if not set yet if (globalRightHand.originalX === undefined) { globalRightHand.originalX = globalRightHand.x; } // Slow scale pulse animation with random left/right movement tween(globalRightHand, { scaleX: 1.1, scaleY: 1.1, x: globalRightHand.originalX + randomXOffset, y: 2732 - 15 // Float up slightly from base position }, { duration: 1800, easing: tween.easeInOut, onFinish: function onFinish() { // Get another random x offset for the return animation var returnRandomXOffset = Math.random() * 40 - 20; tween(globalRightHand, { scaleX: 1.1, scaleY: 1.1, x: globalRightHand.originalX + returnRandomXOffset, y: 2732 // Return to original position }, { duration: 1800, easing: tween.easeInOut, onFinish: _animateHand }); } }); }; // Start the hand animation _animateHand(); } function castRayDDAInto(startX, startY, rayDirX, rayDirY, result) { // Pre-compute map coordinates only once var mapX = Math.floor(startX); var mapY = Math.floor(startY); // Early bounds check to avoid unnecessary computation if (mapX < 0 || mapX >= MAP_SIZE || mapY < 0 || mapY >= MAP_SIZE) { result.wallHit = true; result.distance = MAX_RENDER_DISTANCE; result.side = 0; result.wallType = 1; // Default wall type if out of bounds result.mapX = mapX; result.mapY = mapY; return; } // Pre-compute these reciprocals once to avoid division in the loop var deltaDistX = Math.abs(rayDirX) < 0.0001 ? 9999999 : Math.abs(1 / rayDirX); var deltaDistY = Math.abs(rayDirY) < 0.0001 ? 9999999 : Math.abs(1 / rayDirY); var stepX, stepY, sideDistX, sideDistY; // Optimize direction calculations with ternary operators stepX = rayDirX < 0 ? -1 : 1; sideDistX = rayDirX < 0 ? (startX - mapX) * deltaDistX : (mapX + 1.0 - startX) * deltaDistX; stepY = rayDirY < 0 ? -1 : 1; sideDistY = rayDirY < 0 ? (startY - mapY) * deltaDistY : (mapY + 1.0 - startY) * deltaDistY; // Use a maximum iteration count to prevent potential infinite loops var hit = false; var side = 0; var maxIterations = MAP_SIZE * 2; // Maximum map traversal var iterations = 0; // Main DDA loop with iteration limit while (!hit && iterations < maxIterations) { // Store the smaller of the two side distances for optimization if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; side = 0; } else { sideDistY += deltaDistY; mapY += stepY; side = 1; } // Fast bounds check if (mapX < 0 || mapX >= MAP_SIZE || mapY < 0 || mapY >= MAP_SIZE) { hit = true; // Ray went out of bounds continue; } // Fast wall check - avoid accessing map if possible if (map[mapY] && map[mapY][mapX] && map[mapY][mapX].type === 1) { hit = true; } iterations++; } // Calculate distance precisely for the current ray hit var perpWallDist; if (hit) { // Only calculate precise distance if a wall was hit (or went out of bounds) if (side === 0) { perpWallDist = (mapX - startX + (1 - stepX) / 2) / rayDirX; } else { perpWallDist = (mapY - startY + (1 - stepY) / 2) / rayDirY; } // Clamp distance perpWallDist = Math.max(0.001, Math.min(MAX_RENDER_DISTANCE, perpWallDist)); } else { // No wall hit within maxIterations, treat as max distance perpWallDist = MAX_RENDER_DISTANCE; } result.wallHit = hit; result.distance = perpWallDist; result.side = side; // Determine wallType only if hit and within bounds if (hit && mapX >= 0 && mapX < MAP_SIZE && mapY >= 0 && mapY < MAP_SIZE && map[mapY] && map[mapY][mapX]) { result.wallType = map[mapY][mapX].type; } else { result.wallType = 1; // Default or out-of-bounds wall type } result.mapX = mapX; result.mapY = mapY; } // Separate the raycasting logic from the visual updates function fullRayCasting() { floorCaster.update(player.x, player.y, player.dir); // Pre-calculate values needed for all rays var playerX = player.x; var playerY = player.y; var playerDir = player.dir; // Process all rays at once for (var rayIdx = 0; rayIdx < NUM_RAYS; rayIdx++) { var rayResult = rayResults[rayIdx]; // Use pre-allocated object // Calculate ray angle and direction var rayAngle = playerDir - HALF_FOV + rayIdx / NUM_RAYS * FOV; var rayDirX = Math.cos(rayAngle); var rayDirY = Math.sin(rayAngle); // Get ray hit info - populates rayResult castRayDDAInto(playerX, playerY, rayDirX, rayDirY, rayResult); // rayResult.distance is perpWallDist if (rayResult.wallHit) { // Calculate final strip properties and store in rayResult rayResult.actualDistance = rayResult.distance * Math.cos(rayAngle - playerDir); rayResult.wallHeight = Math.max(STRIP_WIDTH, Math.min(2732, WALL_HEIGHT_FACTOR / rayResult.actualDistance)); var wallX; if (rayResult.side === 0) { wallX = playerY + rayResult.distance * rayDirY; } else { wallX = playerX + rayResult.distance * rayDirX; } var texturePos = wallX * 64; // This scales wallX so one unit of wallX effectively spans 64 original texture slices. // New logic for selecting from slices 6-35 repeating // Original calculation provided an index from 0 to 63 for a 64-slice block. var baseIndexOriginal64 = Math.floor(texturePos) % 64; var numNewSlices = 30; // Slices 6 through 35 inclusive (35 - 6 + 1 = 30 textures) // Map the 0-63 original index to a 0-29 index for our new 30-slice repeating block. var newSliceIndexInRepeatingBlock = baseIndexOriginal64 % numNewSlices; // Offset this 0-29 index to match the desired range of 6-35. var tileIndex = 6 + newSliceIndexInRepeatingBlock; // tileIndex will be from 6 to 35 (e.g. walltile6 to walltile35) // Apply reflection based on side and ray direction, similar to original. // The reflection should map within the new range [6, 35]. var minTexIdNewRange = 6; var maxTexIdNewRange = 35; if (rayResult.side === 0 && rayDirX > 0) { // Reflects: e.g., if min=6, max=35: 6 maps to 35, 7 to 34, ..., 35 to 6. // Formula: new_min + (new_max - current_value_in_new_range) tileIndex = minTexIdNewRange + (maxTexIdNewRange - tileIndex); } if (rayResult.side === 1 && rayDirY < 0) { // Same reflection logic for this case. tileIndex = minTexIdNewRange + (maxTexIdNewRange - tileIndex); } rayResult.textureIndex = tileIndex; rayResult.screenX = Math.round(rayIdx * STRIP_WIDTH + (2048 - NUM_RAYS * STRIP_WIDTH) / 2); // Update strip with calculated values var strip = wallSegments[rayIdx]; strip.x = rayResult.screenX; strip.updateStrip(STRIP_WIDTH, rayResult.wallHeight, rayIdx, rayResult.wallType, rayResult.actualDistance, 0, rayResult.side, rayResult.textureIndex, rayResult.mapX, rayResult.mapY); strip.visible = true; // Add to renderableObjects (cleared each frame in game.update, so always push) renderableObjects.push({ distance: rayResult.actualDistance, object: strip, type: 'wall', originalSortOrder: rayIdx }); } else { // Clear strip if no wall was hit var strip = wallSegments[rayIdx]; if (strip && strip.clearStrip) { strip.clearStrip(); strip.visible = false; } // Ensure wallHit is false for updateRaycastVisuals rayResult.wallHit = false; } } } function updateRaycastVisuals() { // floorCaster.update is handled by the main rayCasting function if this optimization path is taken. // Or, if floor/ceiling should update even on minor moves, it can be called here too. // For now, following prompt implies floor/ceiling only updates on fullRayCasting. // Process all rays using cached data for (var rayIdx = 0; rayIdx < NUM_RAYS; rayIdx++) { var rayResult = rayResults[rayIdx]; // Use cached data if (rayResult.wallHit) { // Check cached wallHit // Update strip with cached values var strip = wallSegments[rayIdx]; strip.x = rayResult.screenX; strip.updateStrip(STRIP_WIDTH, rayResult.wallHeight, rayIdx, rayResult.wallType, rayResult.actualDistance, 0, rayResult.side, rayResult.textureIndex, rayResult.mapX, rayResult.mapY); strip.visible = true; // Add to renderableObjects using cached distance (cleared each frame in game.update, so always push) renderableObjects.push({ distance: rayResult.actualDistance, object: strip, type: 'wall', originalSortOrder: rayIdx }); } else { // Clear strip if no wall was hit (based on cache) var strip = wallSegments[rayIdx]; if (strip && strip.clearStrip) { strip.clearStrip(); strip.visible = false; } } } } function rayCasting() { // Check if player has moved enough to require new raycasting // A small threshold helps prevent floating point inaccuracies from triggering constant raycasts. var playerMoved = Math.abs(player.x - lastPlayerX) > 0.001 || Math.abs(player.y - lastPlayerY) > 0.001 || Math.abs(player.dir - lastPlayerDir) > 0.001; // Only perform full raycasting if needed if (playerMoved || shouldRaycast) { // Perform full raycasting (this also updates floor/ceiling via floorCaster.update) fullRayCasting(); // Update cached player position and state lastPlayerX = player.x; lastPlayerY = player.y; lastPlayerDir = player.dir; shouldRaycast = false; // Reset flag until next explicit request or significant move } else { // Just update the visual rendering of walls with already calculated ray data // Floor/ceiling are NOT updated in this path, as per optimization. updateRaycastVisuals(); } } function renderEntities() { var allDynamicEntities = monsters.concat(treasures).concat(powerUps.filter(function (p) { return !p.collected; })).concat(fountains); if (gate) { allDynamicEntities.push(gate); } // visibleEntities array and local sorting are no longer needed here. // Entities will be added to the global renderableObjects array. for (var i = 0; i < allDynamicEntities.length; i++) { var entity = allDynamicEntities[i]; entity.visible = false; // Hide by default, will be set true if it should be rendered. var dx = entity.mapX - player.x; var dy = entity.mapY - player.y; var dist = Math.sqrt(dx * dx + dy * dy); entity.renderDist = dist; // Store distance for sorting if (dist >= MAX_RENDER_DISTANCE) { // Too far to render continue; } var angle = Math.atan2(dy, dx) - player.dir; // Normalize angle to be within -PI to PI range while (angle < -Math.PI) { angle += Math.PI * 2; } while (angle > Math.PI) { angle -= Math.PI * 2; } if (Math.abs(angle) < HALF_FOV) { // Check if entity is within Field of View var rayHit = castRayToPoint(player.x, player.y, entity.mapX, entity.mapY); // Check if there's a clear Line of Sight to the entity // (dist - 0.5 is a small tolerance, e.g. for entity's own depth/size) if (!rayHit.hit || rayHit.dist > dist - 0.5) { entity.visible = true; // Mark as visible for rendering var screenX = (0.5 + angle / FOV) * 2048; // Project to screen X var height = WALL_HEIGHT_FACTOR / dist; // Calculate perceived height var scale = height / 100; // Assuming base asset height is 100 for scaling entity.x = screenX; // Y position calculation var screenY_horizon = 2732 / 2; var player_camera_height_projection_factor = WALL_HEIGHT_FACTOR * 0.5; // Simulates camera height var screenY_for_floor_at_dist = screenY_horizon + player_camera_height_projection_factor / dist; var z_to_screen_pixels_factor = WALL_HEIGHT_FACTOR; if (entity instanceof Treasure) { // Treasures are on the floor. Their anchor is 0.5 (center). // To place the bottom of the treasure on the 'screenY_for_floor_at_dist' line, // its center (entity.y) must be 'screenY_for_floor_at_dist - actualRenderedHeight / 2'. // 'scale' is (WALL_HEIGHT_FACTOR / dist) / 100, calculated a few lines above. // 'texture.height' is the original asset height. var currentSprite = entity.isOpen ? entity.treasureOpenSprite : entity.treasureSprite; // Per LK guidelines, .width and .height on an asset provide its original dimensions. // The error indicates currentSprite.texture is undefined, so we use currentSprite.height instead. var currentSpriteOriginalHeight = currentSprite.height; var actualRenderedHeight = currentSpriteOriginalHeight * scale; // 'scale' is the calculated scale for this frame // Adjusted from actualRenderedHeight / 2 (which is * 0.5) to * 0.45 // This subtracts a slightly smaller amount, making entity.y larger (lower on screen). entity.y = screenY_for_floor_at_dist - actualRenderedHeight * 0.2; } else if (entity instanceof PowerUp) { var powerUpWorldZ = entity.verticalOffset; // PowerUps have verticalOffset from chest var screenY_offset_due_to_worldZ = powerUpWorldZ * z_to_screen_pixels_factor / dist; // currentBobOffset for PowerUp is in screen pixels. entity.y = screenY_for_floor_at_dist - screenY_offset_due_to_worldZ + entity.currentBobOffset - height / 2; } else if (entity instanceof Fountain) { // Fountains are on the floor, similar to treasures. var fountainSprite = entity.sprite; var fountainSpriteOriginalHeight = fountainSprite.height; var actualRenderedHeightFountain = fountainSpriteOriginalHeight * scale; // Using * 0.2 based on treasure logic to position bottom edge appropriately entity.y = screenY_for_floor_at_dist - actualRenderedHeightFountain * 0.2; } else if (entity instanceof EyeballMonster) { var eyeballWorldZ = entity.baseWorldYOffset; // currentBobbingYOffset removed var screenY_offset_due_to_worldZ = eyeballWorldZ * z_to_screen_pixels_factor / dist; entity.y = screenY_for_floor_at_dist - screenY_offset_due_to_worldZ; // Eyeball anchor is 0.5, already centered. } else if (entity instanceof Monster || entity instanceof Gate || entity instanceof ImpMonster) { // Calculate Y position based on size multiplier (if any) var entitySizeMultiplier = entity.sizeMultiplier || 1.0; // Default to 1.0 if no multiplier var screenY_horizon = 2732 / 2; // Ensure screenY_horizon is defined in this specific scope if not already // 'height' should be available from earlier in the function: var height = WALL_HEIGHT_FACTOR / dist; if (entitySizeMultiplier < 1.0) { // For smaller entities, lower them proportionally // This means their visual base aligns more with larger entities, rather than their center. var sizeReduction = (1 - entitySizeMultiplier) * height * 0.5; // How much smaller they are in terms of screen height entity.y = screenY_horizon + sizeReduction; // Add to lower them (Y increases downwards) } else { // Normal positioning for regular-sized or larger entities (center on horizon) entity.y = screenY_horizon; } } else { // Fallback for any other entity type (should ideally be handled above) entity.y = screenY_horizon; } // Scale adjustments if (entity instanceof PowerUp) { var finalScale = scale * entity.spawnScaleMultiplier; // Apply spawn scale animation entity.scale.set(finalScale, finalScale); } else if (entity.sizeMultiplier !== undefined) { // For monsters with custom size multipliers var customScale = scale * entity.sizeMultiplier; entity.scale.set(customScale, customScale); } else { // Default scaling for other entities entity.scale.set(scale, scale); } // Apply distance-based tinting to entities var SHADE_START_FACTOR_ENTITIES = 0.3; // Match floor/ceiling behavior var MIN_SHADE_ENTITIES = 0.2; // General minimum shade for distant entities var distanceRatioEntities = dist / MAX_RENDER_DISTANCE; var entityShadeFactor = Math.max(MIN_SHADE_ENTITIES, 1 - distanceRatioEntities / SHADE_START_FACTOR_ENTITIES); // --- Apply projectile light to entities --- var totalProjectileLightInfluenceEntity = 0; for (var k = 0; k < projectiles.length; k++) { var proj = projectiles[k]; if (proj.active) { var dxProjEntity = proj.worldX - entity.mapX; var dyProjEntity = proj.worldY - entity.mapY; var distToProjectileEntitySq = dxProjEntity * dxProjEntity + dyProjEntity * dyProjEntity; if (distToProjectileEntitySq < PROJECTILE_LIGHT_RADIUS * PROJECTILE_LIGHT_RADIUS) { var distToProjectileEntity = Math.sqrt(distToProjectileEntitySq); var distRatioProjEntity = distToProjectileEntity / PROJECTILE_LIGHT_RADIUS; var falloffEntity = Math.pow(Math.max(0, 1 - distRatioProjEntity), PROJECTILE_LIGHT_FALLOFF_EXPONENT); totalProjectileLightInfluenceEntity += PROJECTILE_LIGHT_MAX_CONTRIBUTION * falloffEntity; } } } entityShadeFactor = Math.min(1.0, entityShadeFactor + totalProjectileLightInfluenceEntity); // --- End projectile light for entities --- var finalEntityTint; if (entity instanceof Monster || entity instanceof EyeballMonster || entity instanceof ImpMonster) { // For Monster, EyeballMonster, and ImpMonster, 'entity' is the main container. // Tinting the container affects its children sprites. Base tint is white (0xFFFFFF). var r_ge = Math.floor(0xFF * entityShadeFactor); var g_ge = Math.floor(0xFF * entityShadeFactor); var b_ge = Math.floor(0xFF * entityShadeFactor); finalEntityTint = r_ge << 16 | g_ge << 8 | b_ge; entity.tint = finalEntityTint; // Alpha is managed by internal sprite animations or visibility logic. } else if (entity instanceof Treasure) { var baseTreasureTint = 0xdddddd; // Treasure has a specific base tint var r_t = baseTreasureTint >> 16 & 0xFF; var g_t = baseTreasureTint >> 8 & 0xFF; var b_t = baseTreasureTint & 0xFF; r_t = Math.floor(r_t * entityShadeFactor); g_t = Math.floor(g_t * entityShadeFactor); b_t = Math.floor(b_t * entityShadeFactor); finalEntityTint = r_t << 16 | g_t << 8 | b_t; var targetSprite = entity.isOpen ? entity.treasureOpenSprite : entity.treasureSprite; targetSprite.tint = finalEntityTint; // Preserve targetSprite.alpha for open/close animation. } else if (entity instanceof PowerUp) { // PowerUp sprite base tint is white (0xFFFFFF). var r_p = Math.floor(0xFF * entityShadeFactor); var g_p = Math.floor(0xFF * entityShadeFactor); var b_p = Math.floor(0xFF * entityShadeFactor); finalEntityTint = r_p << 16 | g_p << 8 | b_p; entity.powerUpSprite.tint = finalEntityTint; // Preserve entity.powerUpSprite.alpha for collection animation. } else if (entity instanceof Fountain) { var baseFountainTint = entity.state === 'full' ? 0xFFFFFF : 0x707070; // White when full, dark grey when empty var r_ft = baseFountainTint >> 16 & 0xFF; var g_ft = baseFountainTint >> 8 & 0xFF; var b_ft = baseFountainTint & 0xFF; r_ft = Math.floor(r_ft * entityShadeFactor); g_ft = Math.floor(g_ft * entityShadeFactor); b_ft = Math.floor(b_ft * entityShadeFactor); finalEntityTint = r_ft << 16 | g_ft << 8 | b_ft; entity.sprite.tint = finalEntityTint; } else if (entity instanceof Gate) { var baseGateTint = 0x00FF00; // Gate is tinted green var r_ga = baseGateTint >> 16 & 0xFF; var g_ga = baseGateTint >> 8 & 0xFF; var b_ga = baseGateTint & 0xFF; r_ga = Math.floor(r_ga * entityShadeFactor); g_ga = Math.floor(g_ga * entityShadeFactor); b_ga = Math.floor(b_ga * entityShadeFactor); finalEntityTint = r_ga << 16 | g_ga << 8 | b_ga; entity.gateSprite.tint = finalEntityTint; // Preserve entity.gateSprite.alpha for its pulse animation. } // Add to global list for sorting with walls renderableObjects.push({ distance: entity.renderDist, object: entity, type: 'entity' }); } } } // Sorting and adding to dynamicEntitiesContainer is now done in game.update, // after both walls and entities have been added to renderableObjects. // dynamicEntitiesContainer.removeChildren(); // Moved // visibleEntities.sort(...); // Removed // for (var j = 0; j < visibleEntities.length; j++) { // Removed // dynamicEntitiesContainer.addChild(visibleEntities[j]); // } } function castRayToPoint(startX, startY, targetX, targetY) { var rayDirX = targetX - startX; var rayDirY = targetY - startY; var distance = Math.sqrt(rayDirX * rayDirX + rayDirY * rayDirY); rayDirX /= distance; rayDirY /= distance; var mapCheckX = Math.floor(startX); var mapCheckY = Math.floor(startY); var stepSizeX = Math.abs(1 / rayDirX); var stepSizeY = Math.abs(1 / rayDirY); var stepX = rayDirX >= 0 ? 1 : -1; var stepY = rayDirY >= 0 ? 1 : -1; var sideDistX, sideDistY; if (rayDirX < 0) { sideDistX = (startX - mapCheckX) * stepSizeX; } else { sideDistX = (mapCheckX + 1.0 - startX) * stepSizeX; } if (rayDirY < 0) { sideDistY = (startY - mapCheckY) * stepSizeY; } else { sideDistY = (mapCheckY + 1.0 - startY) * stepSizeY; } var hit = false; var side = 0; var distToWall = 0; while (!hit && distToWall < distance) { if (sideDistX < sideDistY) { sideDistX += stepSizeX; mapCheckX += stepX; side = 0; distToWall = sideDistX - stepSizeX; } else { sideDistY += stepSizeY; mapCheckY += stepY; side = 1; distToWall = sideDistY - stepSizeY; } if (mapCheckX < 0 || mapCheckX >= MAP_SIZE || mapCheckY < 0 || mapCheckY >= MAP_SIZE || !map[mapCheckY] || !map[mapCheckY][mapCheckX]) { break; } else if (map[mapCheckY][mapCheckX].type === 1) { hit = true; } } return { hit: hit, dist: distToWall }; } function updateControls() { var joystick = controlButtons.joystick; var currentJoystickX, currentJoystickY, isJoystickConsideredActive; if (joystickOverrideActive) { currentJoystickX = joystickOverrideX; currentJoystickY = joystickOverrideY; isJoystickConsideredActive = true; // Movement continues based on pre-attack state } else if (joystick && joystick.active) { currentJoystickX = joystick.normalizedX; currentJoystickY = joystick.normalizedY; isJoystickConsideredActive = true; } else { currentJoystickX = 0; currentJoystickY = 0; isJoystickConsideredActive = false; } if (isJoystickConsideredActive) { controls.forward = currentJoystickY < -0.3; controls.backward = currentJoystickY > 0.3; controls.left = currentJoystickX < -0.3; controls.right = currentJoystickX > 0.3; } else { controls.forward = false; controls.backward = false; controls.left = false; controls.right = false; } // Check if player is in front of an interactable object (treasure or power-up) var showInteract = false; var interactableObject = null; // Generic variable for treasure or power-up // Priority 1: Check for treasures for (var i = 0; i < treasures.length; i++) { var t = treasures[i]; if (t.isOpen) { continue; } var dx_t = t.mapX - player.x; var dy_t = t.mapY - player.y; var dist_t = Math.sqrt(dx_t * dx_t + dy_t * dy_t); if (dist_t < 0.7) { //{cN} // Interaction range var angleToTreasure = Math.atan2(dy_t, dx_t); var angleDiff_t = Math.abs((player.dir - angleToTreasure + Math.PI * 3) % (Math.PI * 2) - Math.PI); if (angleDiff_t < Math.PI / 3) { // 60-degree cone showInteract = true; interactableObject = t; break; //{cO} } } } // Priority 2: If no treasure, check for power-ups if (!showInteract) { for (var i = 0; i < powerUps.length; i++) { var p = powerUps[i]; if (p.collected) { continue; } var dx_p = p.mapX - player.x; var dy_p = p.mapY - player.y; var dist_p = Math.sqrt(dx_p * dx_p + dy_p * dy_p); if (dist_p < 0.7) { // Same interaction range var angleToPowerUp = Math.atan2(dy_p, dx_p); var angleDiff_p = Math.abs((player.dir - angleToPowerUp + Math.PI * 3) % (Math.PI * 2) - Math.PI); if (angleDiff_p < Math.PI / 3) { // Same 60-degree cone showInteract = true; interactableObject = p; break; } } } } // Priority 3: If no treasure or power-up, check for Fountains if (!showInteract) { for (var i = 0; i < fountains.length; i++) { var f = fountains[i]; if (!f.isInteractable || f.state !== 'full') { continue; } var dx_f = f.mapX - player.x; var dy_f = f.mapY - player.y; var dist_f = Math.sqrt(dx_f * dx_f + dy_f * dy_f); if (dist_f < 0.7) { // Interaction range var angleToFountain = Math.atan2(dy_f, dx_f); var angleDiff_f = Math.abs((player.dir - angleToFountain + Math.PI * 3) % (Math.PI * 2) - Math.PI); if (angleDiff_f < Math.PI / 3) { // 60-degree cone showInteract = true; interactableObject = f; break; } } } } // Update attack button's interact state and target controlButtons.attack.isInteract = showInteract; controlButtons.attack.interactTarget = interactableObject; // Use the new generic property name // Read from attack/interact button controls.attack = controlButtons.attack.pressed; } function getDistanceToNearestWall(x, y) { var minDist = Infinity; // Check a larger area around the player var checkRadius = 2; // Check 2 cells in each direction var startX = Math.max(0, Math.floor(x) - checkRadius); var endX = Math.min(MAP_SIZE - 1, Math.floor(x) + checkRadius); var startY = Math.max(0, Math.floor(y) - checkRadius); var endY = Math.min(MAP_SIZE - 1, Math.floor(y) + checkRadius); for (var cellY = startY; cellY <= endY; cellY++) { for (var cellX = startX; cellX <= endX; cellX++) { if (map[cellY] && map[cellY][cellX] && map[cellY][cellX].type === 1) { // This is a wall cell, calculate distance to the nearest edge of this cell var wallLeft = cellX; var wallRight = cellX + 1; var wallTop = cellY; var wallBottom = cellY + 1; // Calculate distance to the nearest edge of the wall cell var distX = Math.max(0, Math.max(wallLeft - x, x - wallRight)); var distY = Math.max(0, Math.max(wallTop - y, y - wallBottom)); var distToWall = Math.sqrt(distX * distX + distY * distY); minDist = Math.min(minDist, distToWall); } } } return minDist; } function pushPlayerAwayFromWalls() { var currentDist = getDistanceToNearestWall(player.x, player.y); if (currentDist < WALL_BUFFER) { // Player is too close to a wall, find the direction away from the nearest wall var pushDistance = WALL_BUFFER - currentDist + 0.05; // Add small extra margin // Calculate the gradient (direction of steepest distance increase) var gradStep = 0.01; var distRight = getDistanceToNearestWall(player.x + gradStep, player.y); var distLeft = getDistanceToNearestWall(player.x - gradStep, player.y); var distUp = getDistanceToNearestWall(player.x, player.y - gradStep); var distDown = getDistanceToNearestWall(player.x, player.y + gradStep); var gradX = (distRight - distLeft) / (2 * gradStep); var gradY = (distDown - distUp) / (2 * gradStep); // Normalize the gradient var gradMag = Math.sqrt(gradX * gradX + gradY * gradY); if (gradMag > 0) { gradX /= gradMag; gradY /= gradMag; // Move in the direction of the gradient var newX = player.x + gradX * pushDistance; var newY = player.y + gradY * pushDistance; if (canMoveTo(newX, newY)) { player.x = newX; player.y = newY; } else { // If gradient direction doesn't work, try radial push var angles = [0, Math.PI / 4, Math.PI / 2, 3 * Math.PI / 4, Math.PI, 5 * Math.PI / 4, 3 * Math.PI / 2, 7 * Math.PI / 4]; for (var i = 0; i < angles.length; i++) { var testX = player.x + Math.cos(angles[i]) * pushDistance; var testY = player.y + Math.sin(angles[i]) * pushDistance; if (canMoveTo(testX, testY)) { player.x = testX; player.y = testY; break; } } } } } } function canMoveTo(targetX, targetY) { var cellX = Math.floor(targetX); var cellY = Math.floor(targetY); // Check bounds: if target is outside map, cannot move. if (cellX < 0 || cellX >= MAP_SIZE || cellY < 0 || cellY >= MAP_SIZE || !map[cellY] || !map[cellY][cellX]) { return false; } // Check if the target cell itself is a wall if (map[cellY][cellX].type === 1) { return false; } // Prevent walking through treasures (if any treasure is in this cell and not open) for (var i = 0; i < treasures.length; i++) { var t = treasures[i]; if (Math.floor(t.mapX) === cellX && Math.floor(t.mapY) === cellY && !t.isOpen) { return false; } } // Check if the player would be too close to any wall var distToWall = getDistanceToNearestWall(targetX, targetY); return distToWall >= WALL_BUFFER; } function canMonsterMoveTo(targetX, targetY, monster) { var targetCellX = Math.floor(targetX); var targetCellY = Math.floor(targetY); // Check bounds: if target is outside map, cannot move. if (targetCellX < 0 || targetCellX >= MAP_SIZE || targetCellY < 0 || targetCellY >= MAP_SIZE || !map[targetCellY] || !map[targetCellY][targetCellX]) { return false; } // Check if the target cell itself is a wall if (map[targetCellY][targetCellX].type === 1) { return false; } // Check if another monster is already in the target cell for (var i = 0; i < monsters.length; i++) { var otherMonster = monsters[i]; if (otherMonster !== monster && Math.floor(otherMonster.mapX) === targetCellX && Math.floor(otherMonster.mapY) === targetCellY) { return false; // Target cell occupied by another monster } } // Check if the monster would be too close to any wall using its own buffer var distToActualWall = getDistanceToNearestWall(targetX, targetY); return distToActualWall >= MONSTER_WALL_BUFFER; } function updateMonsterPosition(monster, newMapX, newMapY) { var oldCellX = Math.floor(monster.mapX); var oldCellY = Math.floor(monster.mapY); var newCellX = Math.floor(newMapX); var newCellY = Math.floor(newMapY); // Only update map cell occupancy if the monster is actually changing cells if (oldCellX !== newCellX || oldCellY !== newCellY) { if (map[oldCellY] && map[oldCellY][oldCellX]) { // Check if old cell is valid map[oldCellY][oldCellX].removeMonster(); } // else: old cell was invalid or monster wasn't registered there. if (map[newCellY] && map[newCellY][newCellX]) { // Check if new cell is valid map[newCellY][newCellX].addMonster(); } else { // Fallback: If new cell is invalid (this implies an issue if canMonsterMoveTo passed) // Re-register in old cell if it was valid, and don't move. if (map[oldCellY] && map[oldCellY][oldCellX]) { map[oldCellY][oldCellX].addMonster(); } return; // Do not tween to an invalid position. } } // Tween the monster's visual/logical position tween(monster, { mapX: newMapX, mapY: newMapY }, { duration: 300, // 300ms smooth animation easing: function easing(t) { return 1 - Math.pow(1 - t, 4); } // quartOut easing }); } function updatePlayerMovement(deltaTime) { var moveSpeed = PLAYER_MOVE_SPEED * deltaTime; var turnSpeed = PLAYER_TURN_SPEED * deltaTime; var didMove = false; // Track player's last position to detect state changes if (player.lastX === undefined) { player.lastX = player.x; } if (player.lastY === undefined) { player.lastY = player.y; } // Get joystick values for analog control var joystick = controlButtons.joystick; var turnAmount = 0; var moveAmount = 0; var currentJoystickX, currentJoystickY, isJoystickConsideredActiveForMovement; if (joystickOverrideActive) { currentJoystickX = joystickOverrideX; currentJoystickY = joystickOverrideY; isJoystickConsideredActiveForMovement = true; } else if (joystick && joystick.active) { currentJoystickX = joystick.normalizedX; currentJoystickY = joystick.normalizedY; isJoystickConsideredActiveForMovement = true; } else { currentJoystickX = 0; currentJoystickY = 0; isJoystickConsideredActiveForMovement = false; } // Handle rotation - use x-axis for turning if (isJoystickConsideredActiveForMovement) { var joystickMagnitudeX = Math.abs(currentJoystickX); var rampedTurnValue = 0; // Initialize to 0, will be 0 if in dead zone var JOYSTICK_TURN_DEAD_ZONE = 0.15; // Threshold below which joystick input for turning is minimal/zero var JOYSTICK_TURN_RAMP_EXPONENT = 2.5; // Higher value means more ramping towards the edges if (joystickMagnitudeX > JOYSTICK_TURN_DEAD_ZONE) { // Remap the input from [JOYSTICK_TURN_DEAD_ZONE, 1.0] to [0.0, 1.0] var normalizedInputX = (joystickMagnitudeX - JOYSTICK_TURN_DEAD_ZONE) / (1.0 - JOYSTICK_TURN_DEAD_ZONE); // Apply ramping (power curve) rampedTurnValue = Math.pow(normalizedInputX, JOYSTICK_TURN_RAMP_EXPONENT); } var rampedTurnAmount = Math.sign(currentJoystickX) * rampedTurnValue; turnAmount = rampedTurnAmount * turnSpeed * 1.4; // Retain existing overall speed multiplier player.dir += turnAmount; while (player.dir < 0) { player.dir += Math.PI * 2; } while (player.dir >= Math.PI * 2) { player.dir -= Math.PI * 2; } } // Also support digital controls for rotation else if (controls.left) { player.dir -= turnSpeed; while (player.dir < 0) { player.dir += Math.PI * 2; } } else if (controls.right) { player.dir += turnSpeed; while (player.dir >= Math.PI * 2) { player.dir -= Math.PI * 2; } } // Calculate movement vector var dx = 0, dy = 0; // Handle movement - use y-axis for forward/backward var actualMoveSpeed = moveSpeed * player.moveSpeedMultiplier; if (isJoystickConsideredActiveForMovement) { moveAmount = -currentJoystickY * actualMoveSpeed; // Negative because up is negative y if (Math.abs(moveAmount) > 0.01) { dx += Math.cos(player.dir) * moveAmount; dy += Math.sin(player.dir) * moveAmount; didMove = true; } } // Also support digital controls for movement else if (controls.forward) { dx += Math.cos(player.dir) * actualMoveSpeed; dy += Math.sin(player.dir) * actualMoveSpeed; didMove = true; } else if (controls.backward) { dx -= Math.cos(player.dir) * actualMoveSpeed; dy -= Math.sin(player.dir) * actualMoveSpeed; didMove = true; } var inputMoveIntent = didMove; didMove = false; // Enhanced collision detection with proper wall sliding if (dx !== 0 || dy !== 0) { var currentX = player.x; var currentY = player.y; // Try moving to the exact target position first var targetX = currentX + dx; var targetY = currentY + dy; if (canMoveTo(targetX, targetY)) { // No collision, move normally player.x = targetX; player.y = targetY; didMove = true; } else { // Collision detected, try wall sliding var moved = false; // Try moving only along X axis if (dx !== 0 && canMoveTo(currentX + dx, currentY)) { player.x = currentX + dx; moved = true; } // Try moving only along Y axis if (dy !== 0 && canMoveTo(currentX, currentY + dy)) { player.y = currentY + dy; moved = true; } // If basic sliding failed, try gradual movement if (!moved) { var maxSteps = 10; for (var step = 1; step <= maxSteps; step++) { var fraction = step / maxSteps; // Try X movement with varying intensity if (dx !== 0) { var testX = currentX + dx * fraction; if (canMoveTo(testX, currentY)) { player.x = testX; moved = true; break; } } // Try Y movement with varying intensity if (dy !== 0) { var testY = currentY + dy * fraction; if (canMoveTo(currentX, testY)) { player.y = testY; moved = true; break; } } } } didMove = moved; } } // Always ensure player maintains proper distance from walls pushPlayerAwayFromWalls(); // Play walk sound if there was input intent AND actual movement occurred if (didMove && inputMoveIntent && LK.ticks % 20 === 0) { LK.getSound('walk').play(); } // Only attempt to attack if attack button is pressed and we can attack if (controls.attack && canAttack) { attackAction(); } // Check for collisions with monsters checkMonsterCollisions(); // Check for collisions with treasures checkTreasureCollisions(); // Check for gate collision checkGateCollision(); // Check for collisions with power-ups checkPowerUpCollisions(); // Update player marker on minimap updateMiniMap(); // Update player's last position player.lastX = player.x; player.lastY = player.y; } function checkPowerUpCollisions() { for (var i = powerUps.length - 1; i >= 0; i--) { var powerUp = powerUps[i]; if (powerUp.collected) { continue; } var dx = powerUp.mapX - player.x; var dy = powerUp.mapY - player.y; var dist = Math.sqrt(dx * dx + dy * dy); if (dist < 0.6) { // Power-ups are no longer collected by collision. // This block is intentionally left empty or can be removed if no other proximity logic is needed. // For now, we ensure no collection happens here. } } } function attackAction() { // Check if attack is on cooldown if (!canAttack) { return; } // Set attack on cooldown canAttack = false; var currentAttackCooldown = attackCooldown; if (player.attackBuffActive) { currentAttackCooldown /= 2; // Halve cooldown if attack buff is active } // Create and fire projectile var projectile = new Projectile(); projectile.fire(globalRightHand.x, globalRightHand.y - 400); // Add hand recoil animation tween(globalRightHand, { y: 2732 - 50, rotation: -0.1 }, { duration: 100, onFinish: function onFinish() { tween(globalRightHand, { y: 2732, rotation: 0 }, { duration: 300 }); } }); // Add to projectile layer projectiles.push(projectile); projectileLayer.addChild(projectile); // Update attack button visual var attackButton = controlButtons.attack; attackButton.updateCooldown(0); // Cooldown animation var _cooldownTick = function cooldownTick(progress) { attackButton.updateCooldown(progress); if (progress < 1) { LK.setTimeout(function () { _cooldownTick(progress + 0.05); }, currentAttackCooldown / 20); // Use currentAttackCooldown } }; _cooldownTick(0); // Reset cooldown after specified time LK.setTimeout(function () { canAttack = true; }, currentAttackCooldown); // Use currentAttackCooldown } function updateProjectiles(deltaTime) { for (var i = projectiles.length - 1; i >= 0; i--) { var projectile = projectiles[i]; // Update projectile position var remove = projectile.update(deltaTime); // Check if projectile has gone off screen or should be removed if (remove) { // Remove projectile projectile.destroy(); projectiles.splice(i, 1); continue; } // Check if projectile hits a monster var hitMonster = false; var hitMonsterIndex = -1; for (var j = 0; j < monsters.length; j++) { var monster = monsters[j]; if (monster.visible) { // Get the vector from player to monster in world space var monsterDx = monster.mapX - player.x; var monsterDy = monster.mapY - player.y; // Calculate distance to monster in world space var monsterDist = Math.sqrt(monsterDx * monsterDx + monsterDy * monsterDy); // Calculate the projectile's travel distance in world units var projectileWorldDist = projectile.distance; // Calculate depth difference: projectile must be at a similar depth to the monster. var distanceDifference = Math.abs(projectileWorldDist - monsterDist); var depthHit = distanceDifference < 0.5; // Projectile within 0.5 world units of monster's depth. // Calculate horizontal collision based on monster's apparent width. // monster.width is its current scaled width on screen (approximately WALL_HEIGHT_FACTOR / monsterDist). var horizontalDifference = Math.abs(projectile.x - monster.x); // The collision zone is a factor of the monster's full apparent width. // A factor of 0.6 means the projectile's center needs to be within 0.6 * monster.width of the monster's screen center X. // If monster.width is its visual width (e.g. 100px), this means horizontalDifference < 60px. // The monster's visual half-width is monster.width / 2 (e.g. 50px). // So, this allows the projectile's center to be (0.6 - 0.5) = 0.1 * monster.width (e.g. 10px) outside the visual edge. var COLLISION_WIDTH_FACTOR = 0.6; // Adjust this for more/less generous horizontal collision. var horizontalHit = horizontalDifference < monster.width * COLLISION_WIDTH_FACTOR; // Hit if projectile is close enough in depth AND horizontally aligned considering monster's perceived width. if (depthHit && horizontalHit) { // NEW: Check if this projectile has already hit this specific monster if (projectile.hitMonsters.indexOf(monster) !== -1) { continue; // Already hit this monster, check the next one } // If not, this is a new monster for this projectile. Record the hit. projectile.hitMonsters.push(monster); hitMonster = true; hitMonsterIndex = j; break; //{i2} // Found a new monster to hit, break from monster loop } } } // Handle monster hit logic (unchanged) if (hitMonster && hitMonsterIndex !== -1) { var monster = monsters[hitMonsterIndex]; var killed = monster.takeDamage(player.attackPower); // Use player.attackPower // Create a small red particle explosion at the monster's hit location createParticleExplosion(monster.mapX, monster.mapY, 8, 0xFF0000, false); // Red Tint, 8 particles, isWallHit = false if (killed) { // Create larger particle explosion for monster death at monster's world position createParticleExplosion(monster.mapX, monster.mapY, 150, 0xFF0000, false); // Red Tint, isWallHit = false map[Math.floor(monster.mapY)][Math.floor(monster.mapX)].removeMonster(); monster.destroy(); monsters.splice(hitMonsterIndex, 1); player.score += 10; updateUI(); checkLevelCompletion(); } projectile.destroy(); projectiles.splice(i, 1); } } } function checkMonsterCollisions() { var currentTime = Date.now(); for (var i = 0; i < monsters.length; i++) { var monster = monsters[i]; var dx = monster.mapX - player.x; var dy = monster.mapY - player.y; var dist = Math.sqrt(dx * dx + dy * dy); // Determine collision distance based on monster type var collisionDistance = 0.5; // Default if (monster instanceof ImpMonster) { collisionDistance = 0.35; // Imps need to be closer to actually hit the player } if (dist < collisionDistance && monster.canAttack) { // Player hit by monster var damageTaken = 1; if (player.defenseBuffActive) { damageTaken = 0.5; // Halve damage if defense buff is active } player.health -= damageTaken; updateUI(); // Visual feedback LK.effects.flashScreen(0xff0000, 300); // Play sound LK.getSound('hit').play(); // Push player back slightly player.x -= dx * 0.3; player.y -= dy * 0.3; // Set monster attack on cooldown monster.canAttack = false; monster.lastAttackTime = currentTime; // Reset monster attack after cooldown LK.setTimeout(function () { monster.canAttack = true; }, monster.attackCooldown); // Visual feedback for monster attack tween(monster, { alpha: 0.5 }, { duration: 200, onFinish: function onFinish() { tween(monster, { alpha: 1 }, { duration: 200 }); } }); // Check game over if (player.health <= 0) { LK.showGameOver(); } break; } } } function checkTreasureCollisions() { for (var i = treasures.length - 1; i >= 0; i--) { var treasure = treasures[i]; var dx = treasure.mapX - player.x; var dy = treasure.mapY - player.y; var dist = Math.sqrt(dx * dx + dy * dy); // No auto-collect, handled by interact button } } function checkGateCollision() { if (!gate) { return; } var dx = gate.mapX - player.x; var dy = gate.mapY - player.y; var dist = Math.sqrt(dx * dx + dy * dy); if (dist < 0.7) { // Check if all monsters need to be defeated first if (monsters.length > 0) { // Display message that monsters need to be defeated var warningText = new Text2('Defeat all monsters\nbefore exiting!', { size: 60, fill: 0xFF5555 }); warningText.anchor.set(0.5, 0.5); warningText.x = 2048 / 2; warningText.y = 2732 / 2; gameLayer.addChild(warningText); // Remove text after a few seconds LK.setTimeout(function () { warningText.destroy(); }, 2000); return; } // Player reached the gate - complete level // Play collect sound LK.getSound('collect').play(); // Add points for completing level player.score += 50 * player.level; // Remove gate from map map[Math.floor(gate.mapY)][Math.floor(gate.mapX)].removeGate(); gate.destroy(); gate = null; // Level up player.level++; storage.level = player.level; // Restore health, respecting new maxHealth player.health = Math.min(player.health + 2, player.maxHealth); // Update UI updateUI(); // Show level complete with gate messaging var levelCompleteText = new Text2('Level ' + (player.level - 1) + ' Complete!\nYou found the exit!', { size: 80, fill: 0x00FF55 }); levelCompleteText.anchor.set(0.5, 0.5); levelCompleteText.x = 2048 / 2; levelCompleteText.y = 2732 / 2; gameLayer.addChild(levelCompleteText); // Generate new level after a delay LK.setTimeout(function () { levelCompleteText.destroy(); generateMap(); }, 2000); } } function checkLevelCompletion() { // Level is only considered "complete" if all monsters are defeated // This allows the gate to activate if (monsters.length === 0) { // If gate is not visible, make it pulse more dramatically to draw attention if (gate) { tween(gate, { alpha: 0.2 }, { duration: 300, onFinish: function onFinish() { tween(gate, { alpha: 1 }, { duration: 300 }); } }); // Show hint text var gateHintText = new Text2('Find the exit gate!', { size: 60, fill: 0x00FF55 }); gateHintText.anchor.set(0.5, 0.5); gateHintText.x = 2048 / 2; gateHintText.y = 200; gameLayer.addChild(gateHintText); // Remove hint after a few seconds LK.setTimeout(function () { gateHintText.destroy(); }, 3000); } } else { // Show hint text about defeating monsters first if (gate && gate.visible && LK.ticks % 300 === 0) { var monsterHintText = new Text2('Defeat all monsters to activate the exit!', { size: 60, fill: 0xFF5555 }); monsterHintText.anchor.set(0.5, 0.5); monsterHintText.x = 2048 / 2; monsterHintText.y = 200; gameLayer.addChild(monsterHintText); // Remove hint after a few seconds LK.setTimeout(function () { monsterHintText.destroy(); }, 3000); } } } function updateMiniMap() { // Update player marker position on minimap playerMarker.x = miniMap.x + player.x * CELL_SIZE * MINI_MAP_SCALE; playerMarker.y = miniMap.y + player.y * CELL_SIZE * MINI_MAP_SCALE; // Draw player direction indicator var dirX = Math.cos(player.dir) * 15; var dirY = Math.sin(player.dir) * 15; } // Game update method game.update = function () { var currentTime = Date.now(); var deltaTime = currentTime - lastTime; lastTime = currentTime; // Update controls updateControls(); // Update player updatePlayerMovement(deltaTime); // --- Rendering Phase --- renderableObjects = []; // Clear the list for the current frame rayCasting(); // Populates renderableObjects with wall strips renderEntities(); // Populates renderableObjects with dynamic entities // Sort all collected objects by distance (farthest first) // This ensures correct Z-ordering. renderableObjects.sort(function (a, b) { if (a.distance !== b.distance) { return b.distance - a.distance; // Farthest objects (larger distance) come first in the array } // Tie-breaking for objects at the exact same distance: // Render walls on top of entities if distances are equal. // This means entities should come earlier in the sorted list (rendered first). if (a.type === 'entity' && b.type === 'wall') { return -1; // Entity (a) comes before wall (b) } if (a.type === 'wall' && b.type === 'entity') { return 1; // Wall (a) comes after entity (b) } // For two walls at the same distance, maintain their original screen order (left to right) if (a.type === 'wall' && b.type === 'wall') { return a.originalSortOrder - b.originalSortOrder; } // For two entities at same distance, or other unhandled ties, default sort behavior (often stable) return 0; }); // Clear the main container for 3D scene elements and add sorted objects dynamicEntitiesContainer.removeChildren(); for (var i = 0; i < renderableObjects.length; i++) { var ro = renderableObjects[i]; // Only add the object if it's marked as visible. // Note: ro.object.visible might have been set to false by rayCasting (for empty strips) // or renderEntities (for occluded/off-FOV entities). if (ro.object.visible) { dynamicEntitiesContainer.addChild(ro.object); } } // --- End of Rendering Phase --- // Update monsters AI and animations // Monster visibility (ro.object.visible) is now correctly set by renderEntities before this logic. if (LK.ticks % 15 === 0) { // Monster movement less frequent for (var i = 0; i < monsters.length; i++) { MonsterAI.moveTowardsPlayer(monsters[i], player.x, player.y, map); } } for (var i = 0; i < monsters.length; i++) { var monster = monsters[i]; // Check if monster is visible (set by renderEntities) and has moved if (monster.visible && (monster.mapX !== monster.lastMoveX || monster.mapY !== monster.lastMoveY)) { monster.updateAnimation(); // Store current position for next comparison monster.lastMoveX = monster.mapX; monster.lastMoveY = monster.mapY; } } // Update projectiles updateProjectiles(deltaTime); // Update fountain particles for (var i = 0; i < fountains.length; i++) { if (fountains[i].updateParticles) { fountains[i].updateParticles(); } } // Update particle explosions particlePoolManager.updateActiveParticles(); }; // Global function to display power-up effect text messages var powerUpEffectTextDisplaying = null; // To manage one text at a time function showPowerUpEffectText(message) { // If a text is already displaying, remove it first if (powerUpEffectTextDisplaying && powerUpEffectTextDisplaying.parent) { tween.stop(powerUpEffectTextDisplaying); // Stop any ongoing tween tween.stop(powerUpEffectTextDisplaying); // Stop any ongoing tween powerUpEffectTextDisplaying.parent.removeChild(powerUpEffectTextDisplaying); powerUpEffectTextDisplaying.destroy(); powerUpEffectTextDisplaying = null; } var effectText = new Text2(message, { size: 80, fill: 0xFFFFFF, stroke: 0x000000, strokeThickness: 7, align: 'center', wordWrap: true, wordWrapWidth: 1800 }); effectText.anchor.set(0.5, 0.5); effectText.x = 2048 / 2; effectText.y = 2732 / 2; // Positioned higher on the screen effectText.alpha = 1.0; gameLayer.addChild(effectText); // Assuming gameLayer is accessible globally powerUpEffectTextDisplaying = effectText; LK.setTimeout(function () { if (effectText && effectText.parent) { // Check if still valid tween(effectText, { alpha: 0 }, { duration: 500, // Fade out over 0.5 seconds easing: tween.linear, onFinish: function onFinish() { if (effectText.parent) { effectText.parent.removeChild(effectText); } effectText.destroy(); if (powerUpEffectTextDisplaying === effectText) { powerUpEffectTextDisplaying = null; } } }); } }, 2500); // Start fade-out after 2.5 seconds } // Game initialization setupGame(); // Event handlers game.down = function (x, y, obj) { activeControlForGlobalHandlers = null; // Reset at the start of a new touch var eventObjForAttack = Object.assign({}, obj); // Fresh event object for attack button eventObjForAttack.stopPropagation = false; var attackBtn = controlButtons.attack; var attackBtnLocalX = x - attackBtn.x; var attackBtnLocalY = y - attackBtn.y; // Use the same generous hit radius as in the original code var attackBtnHitRadius = 150 * 3.5; var attackBtnDistSq = attackBtnLocalX * attackBtnLocalX + attackBtnLocalY * attackBtnLocalY; if (attackBtnDistSq <= attackBtnHitRadius * attackBtnHitRadius) { activeControlForGlobalHandlers = attackBtn; attackBtn.down(attackBtnLocalX, attackBtnLocalY, eventObjForAttack); // If the touch is within the attack button's area, it's considered handled by the attack button, // regardless of whether it internally decided to stop propagation (e.g. attack on cooldown). // This prevents the joystick from also processing this touch. return; } // If we reach here, the touch was NOT on the attack button. // Now check for joystick interaction using original quadrant check. var joystick = controlButtons.joystick; if (x < 800 && y > 2732 - 800) { activeControlForGlobalHandlers = joystick; var eventObjForJoystick = Object.assign({}, obj); // Fresh event object for joystick eventObjForJoystick.stopPropagation = false; // Pass local coordinates to joystick.down joystick.down(x - joystick.x, y - joystick.y, eventObjForJoystick); } }; game.up = function (x, y, obj) { if (activeControlForGlobalHandlers === controlButtons.attack) { var attackBtnLocalX = x - controlButtons.attack.x; var attackBtnLocalY = y - controlButtons.attack.y; controlButtons.attack.up(attackBtnLocalX, attackBtnLocalY, obj); } else if (activeControlForGlobalHandlers === controlButtons.joystick) { // Only call joystick.up if the joystick itself believes it's active if (controlButtons.joystick.active) { var joystickLocalX = x - controlButtons.joystick.x; var joystickLocalY = y - controlButtons.joystick.y; controlButtons.joystick.up(joystickLocalX, joystickLocalY, obj); } } activeControlForGlobalHandlers = null; // Reset after touch ends }; game.move = function (x, y, obj) { // Handle general screen move if (activeControlForGlobalHandlers === controlButtons.joystick) { // Only call joystick.move if the joystick itself believes it's active if (controlButtons.joystick.active) { var joystickLocalX = x - controlButtons.joystick.x; var joystickLocalY = y - controlButtons.joystick.y; controlButtons.joystick.move(joystickLocalX, joystickLocalY, obj); } } // Attack button doesn't typically have a .move action, so no changes needed for it here. }; var MonsterAI = { moveTowardsPlayer: function moveTowardsPlayer(monster, playerX, playerY, map) { var monsterX = monster.mapX; var monsterY = monster.mapY; var dxToPlayer = playerX - monsterX; var dyToPlayer = playerY - monsterY; var distToPlayer = Math.sqrt(dxToPlayer * dxToPlayer + dyToPlayer * dyToPlayer); // Update monster's perception of player (line of sight) var hasClearLOS = false; if (distToPlayer <= MONSTER_AGGRO_RANGE) { // Only check LOS if within aggro range var rayHit = castRayToPoint(monsterX, monsterY, playerX, playerY); // Player is visible if ray doesn't hit a wall, or hits a wall AFTER the player (minus small tolerance) hasClearLOS = !rayHit.hit || rayHit.dist >= distToPlayer - 0.1; } monster.canSeePlayer = hasClearLOS; // Update this flag based on actual LOS // Determine if monster should chase var shouldChase = false; if (distToPlayer <= MONSTER_AGGRO_RANGE) { if (monster.canSeePlayer) { // Chase if has LOS within aggro range shouldChase = true; } else if (distToPlayer < MONSTER_CLOSE_PROXIMITY_RANGE) { // Or chase if very close (and within aggro), even without LOS shouldChase = true; } } if (!shouldChase) { monster.pathToPlayer = []; // Clear path if not chasing return; } // Monster is now committed to chasing. // For pathfinding, this is where monster.pathToPlayer would be calculated. // For now, direct movement uses a simplified path. monster.pathToPlayer = [{ x: playerX, y: playerY }]; // DISTANCE MANAGEMENT AND ATTACK ANIMATION LOGIC // If monster is close enough (MAX_MONSTER_PLAYER_ENGAGEMENT_DISTANCE), it might try to attack (animation) and then stop moving for this tick. // MIN_MONSTER_PLAYER_DISTANCE is the same as MAX_MONSTER_PLAYER_ENGAGEMENT_DISTANCE (0.6), so one check suffices. // Determine engagement distance based on monster type var engagementDistance = MAX_MONSTER_PLAYER_ENGAGEMENT_DISTANCE; // Default 0.6 if (monster instanceof ImpMonster) { engagementDistance = 0.4; // Imps need to get closer due to their smaller size } if (distToPlayer <= engagementDistance) { if (monster.canAttack) { // 'canAttack' is the cooldown flag managed by checkMonsterCollisions if ((monster instanceof EyeballMonster || monster instanceof ImpMonster) && !monster.isAttacking) { monster.performAttackAnimation(); // Actual damage and cooldown reset (monster.canAttack = false) happens in checkMonsterCollisions. } // For other monster types, attack animation could be triggered here. } return; // Stop movement this tick if in attack range (regardless of attack cooldown status for movement). } // MOVEMENT LOGIC (if not in attack range but shouldChase is true) var effectiveMoveSpeed = typeof monster.moveSpeed !== 'undefined' ? monster.moveSpeed : 0.2; var moveDirX = 0; // Renamed from dirX to avoid conflict if monster has 'dirX' property var moveDirY = 0; // Renamed from dirY if (distToPlayer > 0) { // Avoid division by zero moveDirX = dxToPlayer / distToPlayer; moveDirY = dyToPlayer / distToPlayer; } var potentialNewX = monsterX + moveDirX * effectiveMoveSpeed; var potentialNewY = monsterY + moveDirY * effectiveMoveSpeed; // Try direct move first if (canMonsterMoveTo(potentialNewX, potentialNewY, monster)) { updateMonsterPosition(monster, potentialNewX, potentialNewY); } else { // Collision or too close to wall, try sliding var slid = false; // Try X-only movement if (moveDirX !== 0 && canMonsterMoveTo(monsterX + moveDirX * effectiveMoveSpeed, monsterY, monster)) { updateMonsterPosition(monster, monsterX + moveDirX * effectiveMoveSpeed, monsterY); slid = true; } // Try Y-only movement (only if X-only didn't work or Y component is significant) if (!slid && moveDirY !== 0 && canMonsterMoveTo(monsterX, monsterY + moveDirY * effectiveMoveSpeed, monster)) { updateMonsterPosition(monster, monsterX, monsterY + moveDirY * effectiveMoveSpeed); slid = true; } // If still no move (e.g. stuck in a corner), it will just stay put for this tick. } } }; function ensureMapConnectivity() { // Flood fill from player starting position to check accessibility var visited = []; for (var y = 0; y < MAP_SIZE; y++) { visited[y] = []; for (var x = 0; x < MAP_SIZE; x++) { visited[y][x] = false; } } var queue = []; // Start from player position (1,1) queue.push({ x: 1, y: 1 }); visited[1][1] = true; // Perform flood fill while (queue.length > 0) { var current = queue.shift(); var x = current.x; var y = current.y; // Check all four neighbors var neighbors = [{ x: x + 1, y: y }, { x: x - 1, y: y }, { x: x, y: y + 1 }, { x: x, y: y - 1 }]; for (var i = 0; i < neighbors.length; i++) { var nx = neighbors[i].x; var ny = neighbors[i].y; // Check if neighbor is valid and not visited if (nx >= 0 && nx < MAP_SIZE && ny >= 0 && ny < MAP_SIZE && map[ny][nx].type === 0 && !visited[ny][nx]) { visited[ny][nx] = true; queue.push({ x: nx, y: ny }); } } } // Check for unreachable areas and create paths to them for (var y = 1; y < MAP_SIZE - 1; y++) { for (var x = 1; x < MAP_SIZE - 1; x++) { // If it's a floor tile but wasn't visited, it's unreachable if (map[y][x].type === 0 && !visited[y][x]) { // Find the nearest accessible cell var nearestX = -1; var nearestY = -1; var minDist = MAP_SIZE * MAP_SIZE; for (var cy = 1; cy < MAP_SIZE - 1; cy++) { for (var cx = 1; cx < MAP_SIZE - 1; cx++) { if (visited[cy][cx]) { var dist = (cx - x) * (cx - x) + (cy - y) * (cy - y); if (dist < minDist) { minDist = dist; nearestX = cx; nearestY = cy; } } } } // Create a path from the unreachable area to the nearest accessible area if (nearestX !== -1) { createPath(x, y, nearestX, nearestY); // Update visited map by doing a mini flood fill from this newly connected point var pathQueue = [{ x: x, y: y }]; visited[y][x] = true; while (pathQueue.length > 0) { var current = pathQueue.shift(); var px = current.x; var py = current.y; var pathNeighbors = [{ x: px + 1, y: py }, { x: px - 1, y: py }, { x: px, y: py + 1 }, { x: px, y: py - 1 }]; for (var j = 0; j < pathNeighbors.length; j++) { var nx = pathNeighbors[j].x; var ny = pathNeighbors[j].y; if (nx >= 0 && nx < MAP_SIZE && ny >= 0 && ny < MAP_SIZE && map[ny][nx].type === 0 && !visited[ny][nx]) { visited[ny][nx] = true; pathQueue.push({ x: nx, y: ny }); } } } } } } } } function createPath(startX, startY, endX, endY) { // Determine direction (horizontal or vertical first) if (Math.random() < 0.5) { // Horizontal first, then vertical var x = startX; while (x !== endX) { x += x < endX ? 1 : -1; if (map[startY][x].type === 1) { map[startY][x].setType(0); // Convert wall to floor } } var y = startY; while (y !== endY) { y += y < endY ? 1 : -1; if (map[y][endX].type === 1) { map[y][endX].setType(0); // Convert wall to floor } } } else { // Vertical first, then horizontal var y = startY; while (y !== endY) { y += y < endY ? 1 : -1; if (map[y][startX].type === 1) { map[y][startX].setType(0); // Convert wall to floor } } var x = startX; while (x !== endX) { x += x < endX ? 1 : -1; if (map[endY][x].type === 1) { map[endY][x].setType(0); // Convert wall to floor } } } }
/****
* Plugins
****/
var tween = LK.import("@upit/tween.v1");
var storage = LK.import("@upit/storage.v1");
/****
* Classes
****/
var ControlButton = Container.expand(function (direction) {
var self = Container.call(this);
var buttonSprite = self.attachAsset('controlButton', {
anchorX: 0.5,
anchorY: 0.5,
scaleX: 2.5,
scaleY: 2.5
});
var arrowSprite = self.attachAsset('buttonArrow', {
anchorX: 0.5,
anchorY: 0.5,
scaleX: 2.0,
scaleY: 2.0
});
// Set arrow direction based on button type
if (direction === 'up') {
arrowSprite.rotation = 0;
} else if (direction === 'right') {
arrowSprite.rotation = Math.PI / 2;
} else if (direction === 'down') {
arrowSprite.rotation = Math.PI;
} else if (direction === 'left') {
arrowSprite.rotation = Math.PI * 1.5;
} else if (direction === 'attack') {
arrowSprite.visible = false;
buttonSprite = self.attachAsset('attackButton', {
anchorX: 0.5,
anchorY: 0.5,
scaleX: 3.5,
scaleY: 3.5
});
}
self.direction = direction;
self.pressed = false;
self.updateCooldown = function (ratio) {
if (self.direction === 'attack') {
// If in interact mode, change color and label
if (self.isInteract) {
buttonSprite.tint = 0x33aa33; // Green for interact
// Optionally, overlay a text label (not required, but for clarity)
if (!self.interactLabel) {
self.interactLabel = new Text2('INTERACT', {
size: 40,
fill: 0xFFFFFF
});
self.interactLabel.anchor.set(0.5, 0.5);
self.interactLabel.y = 0;
self.addChild(self.interactLabel);
}
self.interactLabel.visible = true;
buttonSprite.alpha = 1;
} else {
buttonSprite.tint = 0xaa3333; // Default attack color
if (self.interactLabel) {
self.interactLabel.visible = false;
}
// Update the button alpha based on cooldown ratio (0 to 1)
buttonSprite.alpha = 0.3 + ratio * 0.7;
}
}
};
self.down = function (x, y, obj) {
self.pressed = true;
buttonSprite.alpha = 0.7;
if (self.direction === 'attack') {
// Stop propagation to prevent the event from affecting the joystick
obj.stopPropagation = true;
// If joystick is active, store its current state for override
if (controlButtons && controlButtons.joystick && controlButtons.joystick.active) {
joystickOverrideActive = true;
joystickOverrideX = controlButtons.joystick.normalizedX;
joystickOverrideY = controlButtons.joystick.normalizedY;
} else {
// Ensure override is not active if joystick wasn't active at press time
joystickOverrideActive = false;
}
// If interact mode, handle based on interactTarget type
if (self.isInteract && self.interactTarget) {
if (self.interactTarget instanceof Treasure && !self.interactTarget.isOpen) {
var treasureToOpen = self.interactTarget;
treasureToOpen.openTreasure();
// Play sound
LK.getSound('collect').play();
// Award points
player.score += treasureToOpen.value * 5;
updateUI();
// Spawn a random powerup at the treasure's location, with animation
var t = treasureToOpen; // Use the correct treasure object
var powerUpTypes = ['speed', 'defense', 'health', 'attack'];
var randomType = powerUpTypes[Math.floor(Math.random() * powerUpTypes.length)];
var powerUpFromChest = new PowerUp(randomType); //{A} // Renamed to avoid conflict
// Place at the center of the chest (mapX/Y are world coords, will be projected in renderEntities)
powerUpFromChest.mapX = t.mapX;
powerUpFromChest.mapY = t.mapY;
powerUpFromChest.collected = false;
// Set initial multiplier for spawn animation. Scale itself will be handled by renderEntities.
powerUpFromChest.spawnScaleMultiplier = 0.1;
// Initialize new animation properties
powerUpFromChest.verticalOffset = 0;
powerUpFromChest.currentBobOffset = 0;
powerUpFromChest.hasReachedBobHeight = false;
powerUps.push(powerUpFromChest);
if (typeof dynamicEntitiesContainer !== "undefined") {
dynamicEntitiesContainer.addChild(powerUpFromChest);
}
// Combined Animation Sequence: Scale up and Lift
tween(powerUpFromChest, {
spawnScaleMultiplier: 1.0,
verticalOffset: 0.10 // Lift by 0.34 world units (reduced by 15%)
}, {
duration: 1200,
// Slower combined animation (1200ms)
easing: tween.elasticOut,
// Retains a nice "pop"
onFinish: function onFinish() {
// Start bobbing after combined animation
powerUpFromChest.hasReachedBobHeight = true;
powerUpFromChest.startBobbingAnimation();
}
});
// Remove treasure from map after a short delay (for open animation)
LK.setTimeout(function () {
map[Math.floor(t.mapY)][Math.floor(t.mapX)].removeTreasure();
// Do NOT destroy the treasure object, just leave it open and non-collidable
// Remove from treasures array so it doesn't show interact again
var idx = treasures.indexOf(t);
if (idx !== -1) {
treasures.splice(idx, 1);
}
checkLevelCompletion();
}, 500);
} else if (self.interactTarget instanceof PowerUp && !self.interactTarget.collected) {
var powerUpToCollect = self.interactTarget;
powerUpToCollect.applyAndRemove();
// No specific score or immediate level completion for collecting power-ups.
} else if (self.interactTarget instanceof Fountain) {
var fountainToUse = self.interactTarget;
fountainToUse.interact();
// Sound, message, and health refill are handled within fountain.interact()
}
} else if (canAttack) {
attackAction();
}
}
};
self.up = function (x, y, obj) {
self.pressed = false;
if (self.direction === 'attack') {
// Clear joystick override when attack button is released
joystickOverrideActive = false;
if (!canAttack) {
buttonSprite.alpha = 0.3; // Show as disabled
} else {
buttonSprite.alpha = 1;
}
} else {
buttonSprite.alpha = 1;
}
};
return self;
});
var EyeballMonster = Container.expand(function () {
var self = Container.call(this);
self.flySprite = self.attachAsset('eyeballfly', {
anchorX: 0.5,
anchorY: 0.5
});
self.attackSprite = self.attachAsset('eyeballattack', {
anchorX: 0.5,
anchorY: 0.5,
alpha: 0
});
self.health = 2; // Fixed health as per requirement
self.mapX = 0;
self.mapY = 0;
self.moveSpeed = 0.25; // Eyeballs move a bit faster than default monsters
// Properties for attack visuals and game logic compatibility
self.isAttacking = false; // True if currently in attack animation
self.attackDuration = 600; // Duration of the attack visual animation in ms
self.attackCooldown = 2000; // Cooldown for dealing damage (used by checkMonsterCollisions)
self.lastAttackTime = 0; // Timestamp of last damage dealt (used by checkMonsterCollisions)
self.canAttack = true; // Whether this monster can deal damage (used by checkMonsterCollisions)
// Bobbing properties
self.baseWorldYOffset = 0.4; // Base height above ground (world units)
// currentBobbingYOffset, bobAmplitude, and bobSpeed removed as bobbing is disabled.
// For MonsterAI compatibility (canSeePlayer, pathToPlayer might be set by AI)
self.canSeePlayer = false;
self.pathToPlayer = [];
self.lastMoveTime = 0; // For AI movement frequency or other logic
// startBobbing method removed as bobbing animation is disabled.
// This function is called by MonsterAI to trigger the attack *animation*
self.performAttackAnimation = function () {
if (self.isAttacking || !self.parent) {
// Already visually attacking or destroyed
return;
}
self.isAttacking = true;
// tween.stop for currentBobbingYOffset removed as bobbing is disabled.
// Switch to attack sprite by directly setting alpha
self.attackSprite.alpha = 1;
self.flySprite.alpha = 0;
// After attack visual duration, revert to fly sprite
LK.setTimeout(function () {
if (!self.parent) {
return;
} // Check if still exists
// Revert to fly sprite by directly setting alpha
self.attackSprite.alpha = 0;
self.flySprite.alpha = 1;
self.isAttacking = false;
// self.startBobbing() call removed as bobbing is disabled.
}, self.attackDuration);
};
self.takeDamage = function (damageAmount) {
self.health -= damageAmount;
LK.getSound('hit').play();
// Flash the whole container (both sprites are children)
LK.effects.flashObject(self, 0xff0000, 400);
if (self.health <= 0) {
// tween.stop for currentBobbingYOffset removed as bobbing is disabled.
// Ensure attack sprite is hidden if dying mid-attack animation
if (self.attackSprite) {
self.attackSprite.alpha = 0;
}
if (self.flySprite) {
self.flySprite.alpha = 0;
} // Or make it fade out
return true; // Monster is killed
}
return false; // Monster survived
};
// This is called by the main game loop's monster iteration.
// For EyeballMonster, its primary "idle" animation (bobbing) is tween-based and self-managed.
// The attack animation is triggered by performAttackAnimation.
// So, this can be empty or used for other per-tick logic if needed.
self.updateAnimation = function () {
// Bobbing is handled by tweens started in startBobbing.
// Attack sprite switching is handled by performAttackAnimation.
};
return self;
});
var FloorCaster = Container.expand(function () {
var self = Container.call(this);
// Screen dimensions
var SCREEN_WIDTH = 2048;
var SCREEN_HEIGHT = 2732;
var HORIZON_Y = SCREEN_HEIGHT / 2;
// Create containers for floor and ceiling strips
var floorContainer = new Container();
var ceilingContainer = new Container();
self.addChild(floorContainer);
self.addChild(ceilingContainer);
// Store strip pools to avoid creating/destroying objects
var floorStrips = [];
var ceilingStrips = [];
var stripHeight = 4; // Height of each horizontal strip
var numStrips = Math.ceil(HORIZON_Y / stripHeight);
// Initialize floor and ceiling rendering
self.update = function (playerX, playerY, playerDir) {
// Clear existing strips
floorContainer.removeChildren();
ceilingContainer.removeChildren();
// Enhanced shading parameters
var SHADE_START_FACTOR = 0.3; // Start shading even sooner (0.5 instead of 0.7)
var FLOOR_MIN_SHADE = 0.28; // Slightly darker minimum for floor
var CEILING_MIN_SHADE = 0.18; // Darker minimum for ceiling
var WALL_BASE_GRADIENT = 0.20; // Add gradient near walls
// Render floor (bottom half of screen)
var stripIndex = 0;
for (var y = HORIZON_Y; y < SCREEN_HEIGHT; y += stripHeight) {
// Calculate distance from horizon
var distanceFromHorizon = y - HORIZON_Y;
if (distanceFromHorizon <= 0) {
continue;
}
// Calculate world distance for perspective
var worldDistance = WALL_HEIGHT_FACTOR / distanceFromHorizon;
// Skip if too far away
if (worldDistance > MAX_RENDER_DISTANCE) {
continue;
}
// Calculate enhanced shade based on distance (darker = further)
var distanceRatio = worldDistance / MAX_RENDER_DISTANCE;
var shadeFactor = Math.max(FLOOR_MIN_SHADE, 1 - distanceRatio / SHADE_START_FACTOR);
// Wall base gradient - darker near the horizon/walls
var horizonProximity = 1 - distanceFromHorizon / (HORIZON_Y * 0.3);
if (horizonProximity > 0) {
shadeFactor -= horizonProximity * WALL_BASE_GRADIENT;
}
// Create or reuse floor strip
var floorStrip;
if (stripIndex < floorStrips.length) {
floorStrip = floorStrips[stripIndex];
} else {
floorStrip = LK.getAsset('mapFloor', {
anchorX: 0,
anchorY: 0
});
floorStrips.push(floorStrip);
}
// Position and scale the strip
floorStrip.x = 0;
floorStrip.y = y;
floorStrip.width = SCREEN_WIDTH;
floorStrip.height = stripHeight;
floorStrip.alpha = shadeFactor;
// Add to floor container
floorContainer.addChild(floorStrip);
stripIndex++;
}
// Render ceiling (top half of screen)
stripIndex = 0;
for (var y = HORIZON_Y - stripHeight; y >= 0; y -= stripHeight) {
// Calculate distance from horizon
var distanceFromHorizon = HORIZON_Y - y;
if (distanceFromHorizon <= 0) {
continue;
}
// Calculate world distance for perspective
var worldDistance = WALL_HEIGHT_FACTOR / distanceFromHorizon;
// Skip if too far away
if (worldDistance > MAX_RENDER_DISTANCE) {
continue;
}
// Calculate enhanced shade for ceiling (darker than floor)
var distanceRatio = worldDistance / MAX_RENDER_DISTANCE;
var shadeFactor = Math.max(CEILING_MIN_SHADE, 1 - distanceRatio / SHADE_START_FACTOR);
// Add subtle gradient near ceiling/walls
var horizonProximity = 1 - distanceFromHorizon / (HORIZON_Y * 0.3);
if (horizonProximity > 0) {
shadeFactor -= horizonProximity * WALL_BASE_GRADIENT;
}
// Create or reuse ceiling strip
var ceilingStrip;
if (stripIndex < ceilingStrips.length) {
ceilingStrip = ceilingStrips[stripIndex];
} else {
ceilingStrip = LK.getAsset('ceiling', {
anchorX: 0,
anchorY: 0
});
ceilingStrips.push(ceilingStrip);
}
// Position and scale the strip
ceilingStrip.x = 0;
ceilingStrip.y = y;
ceilingStrip.width = SCREEN_WIDTH;
ceilingStrip.height = stripHeight;
ceilingStrip.alpha = shadeFactor;
// Add to ceiling container
ceilingContainer.addChild(ceilingStrip);
stripIndex++;
}
};
return self;
});
var Fountain = Container.expand(function () {
var self = Container.call(this);
self.sprite = self.attachAsset('fountain1', {
anchorX: 0.5,
anchorY: 0.5
});
self.mapX = 0;
self.mapY = 0;
self.state = 'full'; // 'full' or 'empty'
self.isInteractable = true;
self.particleTimer = 0;
self.particleInterval = 4; // Spawn particles a bit frequently for a nice effect
self.init = function (mapX, mapY) {
self.mapX = mapX;
self.mapY = mapY;
self.sprite.tint = 0xFFFFFF; // Start with a bright tint
};
self.updateParticles = function () {
if (self.state === 'full' && self.visible && particlePoolManager && particleExplosionContainer) {
self.particleTimer++;
if (self.particleTimer >= self.particleInterval) {
self.particleTimer = 0;
var count = 1 + Math.floor(Math.random() * 2); // 1 or 2 particles per emission
for (var i = 0; i < count; i++) {
var particle = particlePoolManager.getParticle();
if (!particle) {
continue;
}
var emissionWorldX = self.mapX;
var emissionWorldY = self.mapY;
// Assuming the fountain model's "nozzle" is around 1.0 world unit high from its base.
var emissionWorldZ = 1.0;
// Simulate a world-relative radial splash
var splashAngle = Math.random() * Math.PI * 2; // Full circle for radial splash
var horizontalSpeed = Math.random() * 0.0020 + 0.0005; // Small horizontal speed for gentle splash
var vx = Math.cos(splashAngle) * horizontalSpeed;
var vy = Math.sin(splashAngle) * horizontalSpeed;
var vz = Math.random() * 0.018 + 0.012; // Gentle upward velocity
var life = 50 + Math.random() * 40; // Shorter life for fountain particles
var tintColor = 0x66B2FF; // Light blue, slightly less intense
particle.init(emissionWorldX, emissionWorldY, emissionWorldZ, vx, vy, vz, life, tintColor);
particle.collisionImmunity = 3;
particleExplosionContainer.addChild(particle);
}
}
}
};
self.interact = function () {
if (self.state === 'full' && self.isInteractable) {
self.state = 'empty';
self.isInteractable = false;
self.sprite.tint = 0x707070; // Darken the fountain significantly
player.health = player.maxHealth;
updateUI();
showPowerUpEffectText("Your health has been fully restored!");
LK.getSound('collect').play();
}
};
return self;
});
var Gate = Container.expand(function () {
var self = Container.call(this);
// Create gate visual using existing wall asset but with a different color
var gateSprite = self.attachAsset('wall', {
anchorX: 0.5,
anchorY: 0.5
});
// Make the gate distinct with a green tint
gateSprite.tint = 0x00FF00;
self.gateSprite = gateSprite;
self.mapX = 0;
self.mapY = 0;
// Pulse animation to make gate more visible
var _animateGate = function animateGate() {
tween(gateSprite, {
alpha: 0.7,
scaleX: 1.1,
scaleY: 1.1
}, {
duration: 1000,
onFinish: function onFinish() {
tween(gateSprite, {
alpha: 1,
scaleX: 1.0,
scaleY: 1.0
}, {
duration: 1000,
onFinish: _animateGate
});
}
});
};
// Start the pulsing animation
_animateGate();
return self;
});
var ImpMonster = Container.expand(function () {
var self = Container.call(this);
var frameContainer = new Container();
self.addChild(frameContainer);
// Scale imp assets to be visually as tall as the Ogre monster (approx 170px effective height)
// impwalk1 original height: 200px. Scale: 170/200 = 0.85
var impScale = 0.85;
self.sizeMultiplier = 0.7;
var walkFrame1 = LK.getAsset('impwalk1', {
anchorX: 0.5,
anchorY: 0.5,
scaleX: impScale,
scaleY: impScale
});
var walkFrame2 = LK.getAsset('impwalk2', {
anchorX: 0.5,
anchorY: 0.5,
scaleX: impScale,
scaleY: impScale
});
var attackFrameAsset = LK.getAsset('impattack', {
anchorX: 0.5,
anchorY: 0.5,
scaleX: impScale,
scaleY: impScale,
alpha: 0
});
frameContainer.addChild(walkFrame1);
frameContainer.addChild(walkFrame2);
frameContainer.addChild(attackFrameAsset);
walkFrame1.alpha = 1;
walkFrame2.alpha = 0;
self.currentFrame = 1; // 1 for walkFrame1, 2 for walkFrame2
self.animationTick = 0;
self.mapX = 0;
self.mapY = 0;
self.health = 1; // Ogre monster has 6, Eyeball has 2
self.moveSpeed = 0.3; // Ogre default 0.2, Eyeball 0.25. Imp is faster.
self.lastMoveTime = 0;
self.canSeePlayer = false;
self.pathToPlayer = [];
// Attack properties
self.attackCooldown = 1500; // ms (Ogre/Eyeball 2000ms). Imp attacks faster.
self.lastAttackTime = 0;
self.canAttack = true; // Cooldown flag for dealing damage
self.isAttacking = false; // Animation state flag
self.attackDuration = 500; // ms for the attack visual (Eyeball 600ms)
self.updateAnimation = function () {
if (self.isAttacking) {
// If currently in attack animation, don't update walk animation
return;
}
self.animationTick++;
// Change animation frame every 15 ticks (matches Ogre monster animation speed)
if (self.animationTick >= 15) {
self.animationTick = 0;
self.currentFrame = self.currentFrame === 1 ? 2 : 1;
if (self.currentFrame === 1) {
walkFrame1.alpha = 1;
walkFrame2.alpha = 0;
} else {
walkFrame1.alpha = 0;
walkFrame2.alpha = 1;
}
}
};
self.performAttackAnimation = function () {
if (self.isAttacking || !self.parent) {
// Already visually attacking or destroyed
return;
}
self.isAttacking = true;
// Hide walking frames, show attack frame
walkFrame1.alpha = 0;
walkFrame2.alpha = 0;
attackFrameAsset.alpha = 1;
LK.setTimeout(function () {
if (!self.parent) {
// Check if still exists
return;
}
attackFrameAsset.alpha = 0; // Hide attack frame
// Revert to the current walking frame (don't reset walk animation cycle)
if (self.currentFrame === 1) {
walkFrame1.alpha = 1;
walkFrame2.alpha = 0; // Ensure other walk frame is hidden
} else {
// currentFrame is 2
walkFrame1.alpha = 0; // Ensure other walk frame is hidden
walkFrame2.alpha = 1;
}
self.isAttacking = false;
}, self.attackDuration);
};
self.takeDamage = function (damageAmount) {
self.health -= damageAmount;
LK.getSound('hit').play();
// Flash the frameContainer which holds all visual sprites for the imp
LK.effects.flashObject(frameContainer, 0xff0000, 400);
if (self.health <= 0) {
// Ensure all sprites are hidden if dying to prevent lingering visuals
walkFrame1.alpha = 0;
walkFrame2.alpha = 0;
if (attackFrameAsset) {
attackFrameAsset.alpha = 0;
}
return true; // Monster is killed
}
return false; // Monster survived
};
return self;
});
var JoystickController = Container.expand(function () {
var self = Container.call(this);
// Create joystick base
var baseRadius = 150;
var baseSprite = self.attachAsset('controlButton', {
anchorX: 0.5,
anchorY: 0.5,
scaleX: 5.0,
scaleY: 5.0,
alpha: 0.4
});
// Create joystick handle
var handleRadius = 100;
var handleSprite = self.attachAsset('controlButton', {
anchorX: 0.5,
anchorY: 0.5,
scaleX: 3.0,
scaleY: 3.0
});
// Initialize variables
self.active = false;
self.startX = 0;
self.startY = 0;
self.maxDistance = baseRadius;
self.normalizedX = 0;
self.normalizedY = 0;
// Reset the joystick handle position
self.resetHandle = function () {
handleSprite.x = 0;
handleSprite.y = 0;
self.normalizedX = 0;
self.normalizedY = 0;
self.active = false;
};
// Handle touch down event
self.down = function (x, y, obj) {
self.active = true;
self.startX = x;
self.startY = y;
};
// Handle touch move event
self.move = function (x, y, obj) {
if (!self.active) {
return;
}
// Calculate distance from start position
var dx = x - self.startX;
var dy = y - self.startY;
var distance = Math.sqrt(dx * dx + dy * dy);
// Normalize the distance to get direction vector
if (distance > 0) {
// Clamp to max distance
if (distance > self.maxDistance) {
dx = dx * self.maxDistance / distance;
dy = dy * self.maxDistance / distance;
distance = self.maxDistance;
}
// Set handle position
handleSprite.x = dx;
handleSprite.y = dy;
// Calculate normalized values (-1 to 1)
self.normalizedX = dx / self.maxDistance;
self.normalizedY = dy / self.maxDistance;
} else {
self.resetHandle();
}
};
// Handle touch up event
self.up = function (x, y, obj) {
self.resetHandle();
};
// Initialize with handle at center
self.resetHandle();
return self;
});
var MapCell = Container.expand(function () {
var self = Container.call(this);
self.type = 0; // 0 = floor, 1 = wall
self.monster = null;
self.treasure = null;
self.gate = null;
self.setType = function (type) {
self.type = type;
self.updateVisual();
};
self.updateVisual = function () {
self.removeChildren();
if (self.type === 1) {
self.attachAsset('mapWall', {
anchorX: 0,
anchorY: 0
});
} else {
self.attachAsset('mapFloor', {
anchorX: 0,
anchorY: 0
});
}
};
self.addMonster = function () {
if (self.type === 0 && !self.monster && !self.treasure && !self.gate) {
self.monster = true;
return true;
}
return false;
};
self.addTreasure = function () {
if (self.type === 0 && !self.monster && !self.treasure && !self.gate) {
self.treasure = true;
return true;
}
return false;
};
self.removeMonster = function () {
self.monster = null;
};
self.removeTreasure = function () {
self.treasure = null;
};
self.addGate = function () {
if (self.type === 0 && !self.monster && !self.treasure && !self.gate) {
// Gate check is already there, good.
self.gate = true;
return true;
}
return false;
};
self.removeGate = function () {
self.gate = null;
};
return self;
});
var Monster = Container.expand(function () {
var self = Container.call(this);
// Create animation frame container
var frameContainer = new Container();
self.addChild(frameContainer);
// Create monster animation frames
var monsterFrame1 = LK.getAsset('demonOgreWalk1', {
anchorX: 0.5,
anchorY: 0.5
});
var monsterFrame2 = LK.getAsset('demonOgreWalk2', {
anchorX: 0.5,
anchorY: 0.5
});
// Add frames to container
frameContainer.addChild(monsterFrame1);
frameContainer.addChild(monsterFrame2);
// Set initial visibility
monsterFrame1.alpha = 1;
monsterFrame2.alpha = 0;
// Initialize animation state
self.currentFrame = 1;
self.animationTick = 0;
self.mapX = 0;
self.mapY = 0;
self.health = 6;
self.lastMoveTime = 0;
self.canSeePlayer = false;
self.pathToPlayer = [];
self.attackCooldown = 2000; // 2 seconds between attacks
self.lastAttackTime = 0; // When monster last attacked
self.canAttack = true; // Whether monster can attack
// Update animation frames - will be called from game loop
self.updateAnimation = function () {
self.animationTick++;
// Change animation frame every 30 ticks (half second at 60fps)
if (self.animationTick >= 15) {
self.animationTick = 0;
self.currentFrame = self.currentFrame === 1 ? 2 : 1;
// Directly change alpha values without tween
if (self.currentFrame === 1) {
monsterFrame1.alpha = 1;
monsterFrame2.alpha = 0;
} else {
monsterFrame1.alpha = 0;
monsterFrame2.alpha = 1;
}
}
};
self.takeDamage = function (damageAmount) {
self.health -= damageAmount;
LK.getSound('hit').play();
// Visual feedback for hit - flash the monster red
LK.effects.flashObject(frameContainer, 0xff0000, 400);
return self.health <= 0;
};
return self;
});
var Particle = Container.expand(function () {
var self = Container.call(this);
var graphics = self.attachAsset('particle', {
anchorX: 0.5,
anchorY: 0.5
});
// World coordinates and velocities
self.worldX = 0;
self.worldY = 0;
self.worldZ = 0; // Height above the dungeon floor
self.vx = 0; // World velocity X
self.vy = 0; // World velocity Y
self.vz = 0; // World velocity Z (vertical)
self.life = 0;
self.maxLife = 480; // Approx 8 seconds at 60 FPS (Doubled)
self.bounces = 0;
self.tintColor = 0xFFFFFF; // Default tint
// Particle physics constants (world-based)
var PARTICLE_WORLD_GRAVITY_EFFECT = 0.0035; // Gravity pulling worldZ down (Reduced by half)
var PARTICLE_Z_DAMPING = 0.6; // Energy retained vertically after floor bounce (Increased for higher bounce)
var PARTICLE_XY_DAMPING = 0.4; // Energy retained horizontally after wall bounce (Reduced from 0.6)
var PARTICLE_GROUND_FRICTION = 0.8; // Friction for vx, vy when hitting floor (Reduced from 0.9)
var PARTICLE_MAX_BOUNCES = 4;
var PARTICLE_VISUAL_SIZE_AT_UNIT_DISTANCE = 0.08; // Visual size (in world units) when 1 unit away
var MAX_RENDER_DISTANCE_PARTICLES = 12; // Max distance to render particles
self.init = function (wX, wY, wZ, velX, velY, velZ, particleLife, tintColor) {
self.worldX = wX;
self.worldY = wY;
self.worldZ = wZ;
self.vx = velX;
self.vy = velY;
self.vz = velZ;
self.life = particleLife || self.maxLife;
self.bounces = 0;
self.collisionImmunity = 0; // No immunity by default
self.tintColor = tintColor || 0xFFFFFF; // Store and apply tint
graphics.tint = self.tintColor;
graphics.alpha = 1;
// Initial scale will be set in update based on distance
self.visible = false; // Will be set true if in FOV during update
};
self.update = function () {
if (self.life <= 0 || self.bounces >= PARTICLE_MAX_BOUNCES) {
self.visible = false;
return false; // Indicate inactive
}
// Store pre-move position for collision response
var prevWorldX = self.worldX;
var prevWorldY = self.worldY;
// Apply world velocities
self.worldX += self.vx;
self.worldY += self.vy;
self.worldZ += self.vz;
// Apply gravity to vertical velocity
self.vz -= PARTICLE_WORLD_GRAVITY_EFFECT;
// Floor bounce logic (worldZ = 0 is the floor)
if (self.worldZ < 0) {
self.worldZ = 0;
self.vz *= -PARTICLE_Z_DAMPING;
self.vx *= PARTICLE_GROUND_FRICTION;
self.vy *= PARTICLE_GROUND_FRICTION;
self.bounces++;
if (Math.abs(self.vz) < 0.005 && PARTICLE_WORLD_GRAVITY_EFFECT > Math.abs(self.vz)) {
// Come to rest
self.vz = 0;
}
}
// Wall bounce logic - skip if particle has collision immunity
if (self.collisionImmunity > 0) {
self.collisionImmunity--;
} else {
var currentMapX = Math.floor(self.worldX);
var currentMapY = Math.floor(self.worldY);
if (currentMapX < 0 || currentMapX >= MAP_SIZE || currentMapY < 0 || currentMapY >= MAP_SIZE || map[currentMapY] && map[currentMapY][currentMapX] && map[currentMapY][currentMapX].type === 1) {
self.worldX = prevWorldX; // Revert to position before entering wall
self.worldY = prevWorldY;
var pt_map_prev_x = Math.floor(prevWorldX);
var pt_map_prev_y = Math.floor(prevWorldY);
var reflectedX = false;
var reflectedY = false;
// Check if collision was primarily due to X movement into a wall
if (currentMapX !== pt_map_prev_x && map[pt_map_prev_y] && map[pt_map_prev_y][currentMapX] && map[pt_map_prev_y][currentMapX].type === 1) {
self.vx *= -PARTICLE_XY_DAMPING;
reflectedX = true;
}
// Check if collision was primarily due to Y movement into a wall
if (currentMapY !== pt_map_prev_y && map[currentMapY] && map[currentMapY][pt_map_prev_x] && map[currentMapY][pt_map_prev_x].type === 1) {
self.vy *= -PARTICLE_XY_DAMPING;
reflectedY = true;
}
// If hit a corner or exact cause is ambiguous (e.g. started in wall), reflect based on dominant velocity or both
if (!reflectedX && !reflectedY) {
// This case implies it was already in a wall or hit a corner perfectly.
// A simple heuristic: reflect the larger velocity component, or both if similar.
if (Math.abs(self.vx) > Math.abs(self.vy) * 1.2) {
self.vx *= -PARTICLE_XY_DAMPING;
} else if (Math.abs(self.vy) > Math.abs(self.vx) * 1.2) {
self.vy *= -PARTICLE_XY_DAMPING;
} else {
// Similar magnitude or started in wall, reflect both
self.vx *= -PARTICLE_XY_DAMPING;
self.vy *= -PARTICLE_XY_DAMPING;
}
}
self.bounces++;
}
}
// Calculate screen position and scale (similar to projectiles/monsters)
var dx = self.worldX - player.x;
var dy = self.worldY - player.y;
var distToPlayerPlane = Math.sqrt(dx * dx + dy * dy);
if (distToPlayerPlane < 0.1) {
distToPlayerPlane = 0.1;
} // Avoid division by zero / extreme scales
var angle = Math.atan2(dy, dx) - player.dir;
while (angle < -Math.PI) {
angle += Math.PI * 2;
}
while (angle > Math.PI) {
angle -= Math.PI * 2;
}
if (Math.abs(angle) < HALF_FOV && distToPlayerPlane < MAX_RENDER_DISTANCE_PARTICLES) {
self.visible = true;
// Screen X
self.x = 2048 / 2 + angle / HALF_FOV * (2048 / 2);
// Screen Y: based on horizon, distance, and particle's worldZ
var screenY_horizon = 2732 / 2;
var z_to_screen_pixels_factor = WALL_HEIGHT_FACTOR; // Factor for scaling worldZ to screen pixels, effectively how many screen pixels 1 world unit of height is at 1 world unit of distance.
var player_camera_height_projection_factor = WALL_HEIGHT_FACTOR * 0.5; // This factor determines how the floor plane "drops" from the horizon based on distance, simulating camera height.
// Using 0.5 of WALL_HEIGHT_FACTOR aligns it with how treasure bottoms are projected.
// Calculate the screen Y position for a point that is ON THE FLOOR (i.e., worldZ = 0)
// at the particle's current distance (distToPlayerPlane).
// Closer points on the floor will have a larger Y value (lower on screen).
var screenY_for_floor_at_dist = screenY_horizon + player_camera_height_projection_factor / distToPlayerPlane;
// Calculate the screen Y offset caused by the particle's actual height (self.worldZ) above the floor.
// This offset is relative to the screenY_for_floor_at_dist.
var screenY_offset_due_to_worldZ = self.worldZ * z_to_screen_pixels_factor / distToPlayerPlane;
// The final screen Y is the floor's projected Y minus the offset due to the particle's height.
self.y = screenY_for_floor_at_dist - screenY_offset_due_to_worldZ;
// Scale based on distance and particle's inherent visual size
var particleAssetBaseSize = 100.0; // Updated for 'particle' asset (100x100)
var effectiveScreenSize = PARTICLE_VISUAL_SIZE_AT_UNIT_DISTANCE * WALL_HEIGHT_FACTOR / distToPlayerPlane;
var screenScaleFactor = Math.max(0.05, effectiveScreenSize / particleAssetBaseSize);
graphics.scale.set(screenScaleFactor);
var lifeRatio = Math.max(0, self.life / self.maxLife);
graphics.alpha = lifeRatio * 0.8 + 0.2; // Fade out but maintain some visibility
} else {
self.visible = false;
}
self.life--;
if (self.life <= 0 || self.bounces >= PARTICLE_MAX_BOUNCES) {
self.visible = false;
return false; // Indicate inactive
}
return true; // Indicate active
};
return self;
});
var PowerUp = Container.expand(function (type) {
var self = Container.call(this);
self.powerUpType = type; // 'speed', 'defense', 'health', 'attack'
self.spawnScaleMultiplier = 1.0; // Used for spawn animation, defaults to full size
self.mapX = 0;
self.mapY = 0;
self.collected = false;
self.verticalOffset = 0; // World units for lift from chest
self.currentBobOffset = 0; // Screen pixels for bobbing motion
self.hasReachedBobHeight = false; // Flag to start bobbing
var assetId = '';
switch (self.powerUpType) {
case 'speed':
assetId = 'speedup';
break;
case 'defense':
assetId = 'armorup';
break;
case 'health':
assetId = 'healthup';
break;
case 'attack':
assetId = 'attackup';
break;
default:
console.error("Unknown power-up type: " + self.powerUpType);
assetId = 'treasure'; // Fallback, should not happen
break;
}
var powerUpSprite = self.attachAsset(assetId, {
anchorX: 0.5,
anchorY: 0.5
});
self.powerUpSprite = powerUpSprite; // Expose the sprite
self.startBobbingAnimation = function () {
if (self.collected || !self.parent || !self.hasReachedBobHeight) {
return;
}
var bobAmplitude = 15; // Screen pixels for bobbing up/down
var bobSpeed = 1800; // Milliseconds for a full bob cycle ( थोड़ा धीमा )
function animateUpInternal() {
if (self.collected || !self.parent) {
return;
} // Check again before starting tween
tween(self, {
currentBobOffset: -bobAmplitude
}, {
duration: bobSpeed / 2,
easing: tween.easeInOut,
onFinish: function onFinish() {
if (self.collected || !self.parent) {
return;
}
animateDownInternal();
}
});
}
function animateDownInternal() {
if (self.collected || !self.parent) {
return;
} // Check again
tween(self, {
currentBobOffset: 0
}, {
// Bob back to its lifted base
duration: bobSpeed / 2,
easing: tween.easeInOut,
onFinish: function onFinish() {
if (self.collected || !self.parent) {
return;
}
animateUpInternal();
}
});
}
animateUpInternal(); // Start the bobbing cycle
};
// Note: _animateBob() call removed, startBobbingAnimation will be triggered after lift.
self.applyAndRemove = function () {
if (self.collected) {
return;
}
self.collected = true;
var effectMessage = "";
// Apply power-up effect (now permanent)
switch (self.powerUpType) {
case 'health':
player.maxHealth += 1;
player.health = Math.min(player.health + 1, player.maxHealth);
effectMessage = "Max Health +1\nHealth Refilled +1";
updateUI();
break;
case 'speed':
player.moveSpeedMultiplier += 0.15; // Smaller, incremental speed boost
effectMessage = "Movement Speed Increased!";
break;
case 'defense':
player.defenseBuffActive = true; // Permanent defense buff
effectMessage = "Defense Increased!";
break;
case 'attack':
player.attackPower += 1; // Increase attack power
// player.attackBuffActive is no longer set by this power-up, as it affects power not speed/cooldown.
effectMessage = "Attack Power Increased!";
break;
}
if (effectMessage) {
showPowerUpEffectText(effectMessage);
}
LK.getSound('collect').play();
// Visual feedback for collection (quick scale out and fade)
tween(self, {
scaleX: self.scale.x * 0.1,
scaleY: self.scale.y * 0.1,
alpha: 0
}, {
duration: 300,
easing: tween.easeIn,
// Make it disappear inwards
onFinish: function onFinish() {
if (self.parent) {
self.parent.removeChild(self);
}
var index = powerUps.indexOf(self);
if (index !== -1) {
powerUps.splice(index, 1);
}
self.destroy(); // Explicitly destroy after removal
}
});
};
return self;
});
var Projectile = Container.expand(function () {
var self = Container.call(this);
var projectileSprite = self.attachAsset('projectile', {
anchorX: 0.5,
anchorY: 0.5
});
// World position and movement properties
self.worldX = 0;
self.worldY = 0;
self.dirX = 0;
self.dirY = 0;
self.speed = 0.0375; // World units per tick (reduced to 1/4 of original)
self.active = false;
self.distance = 0;
self.maxDistance = 10; // Maximum travel distance in world units
// Initialize projectile
self.fire = function (screenX, screenY) {
// Start at player position, slightly offset in the firing direction
self.worldX = player.x + Math.cos(player.dir) * 0.3;
self.worldY = player.y + Math.sin(player.dir) * 0.3;
// Direction is player's current facing direction
self.dirX = Math.cos(player.dir);
self.dirY = Math.sin(player.dir);
self.active = true;
self.distance = 0;
self.hitMonsters = []; // Initialize list to track hit monsters
// Play attack sound
LK.getSound('attack').play();
// Scale for visual effect
projectileSprite.scale.set(0.2, 0.2);
// Store initial screen coordinates from hand
self.initialScreenX = screenX || 2048 / 2;
self.initialScreenY = screenY || 2732 - 300;
// Set initial position to hand location
self.x = self.initialScreenX;
self.y = self.initialScreenY;
self.visible = true;
};
// Update projectile position and check for collisions
self.update = function (deltaTime) {
if (!self.active) {
return false;
}
// Move projectile in world space
self.worldX += self.dirX * self.speed;
self.worldY += self.dirY * self.speed;
// Track distance traveled
self.distance += self.speed;
// Check for wall collision
var mapX = Math.floor(self.worldX);
var mapY = Math.floor(self.worldY);
var shouldRemove = false;
var hitActualMapWall = false;
if (self.distance >= self.maxDistance) {
shouldRemove = true;
} else if (mapX < 0 || mapX >= MAP_SIZE || mapY < 0 || mapY >= MAP_SIZE) {
// Projectile went out of bounds
shouldRemove = true;
// We could optionally treat out-of-bounds as a wall hit for particles,
// but typically particles are for hitting actual geometry.
} else if (map[mapY] && map[mapY][mapX] && map[mapY][mapX].type === 1) {
// Projectile hit an actual map wall
shouldRemove = true;
hitActualMapWall = true;
}
if (shouldRemove) {
if (hitActualMapWall) {
// Spawn particle feedback at the point of impact
createParticleExplosion(self.worldX, self.worldY, PROJECTILE_WALL_HIT_PARTICLE_COUNT, 0x87CEFA, true); // Light Blue Tint, isWallHit = true
}
return true; // Remove projectile
}
// Position on screen based on player view (raycasting principles)
self.updateScreenPosition();
return false; // Keep projectile
};
// Calculate screen position from world position
self.updateScreenPosition = function () {
// Vector from player to projectile
var dx = self.worldX - player.x;
var dy = self.worldY - player.y;
// Distance from player to projectile
var dist = Math.sqrt(dx * dx + dy * dy);
// Angle from player to projectile
var angle = Math.atan2(dy, dx) - player.dir;
// Normalize angle (-PI to PI)
while (angle < -Math.PI) {
angle += Math.PI * 2;
}
while (angle > Math.PI) {
angle -= Math.PI * 2;
}
// Check if projectile is in field of view
if (Math.abs(angle) < HALF_FOV) {
// Calculate screen X based on angle in FOV
self.x = 2048 / 2 + angle / HALF_FOV * (2048 / 2);
// Calculate target Y position (center of screen with slight adjustment)
var targetY = 2732 / 2 + 40 - WALL_HEIGHT_FACTOR / dist * 0.1; // Adjusted +20 to +40 to lower target height
// Calculate transition factor based on distance
// As distance increases, move closer to target Y
var transitionFactor = Math.min(1.0, self.distance * 1);
// Interpolate between initial hand Y and target Y
self.y = self.initialScreenY * (1 - transitionFactor) + targetY * transitionFactor;
// Scale based on distance
var scale = Math.max(0.1, 2 / dist);
projectileSprite.scale.set(scale, scale);
self.visible = true;
} else {
self.visible = false;
}
};
return self;
});
var RaycastStrip = Container.expand(function () {
var self = Container.call(this);
var activeWallSprite = null; // The currently displayed wall sprite instance
var currentTileIndex = null; // Stores the numeric index of activeWallSprite, e.g., 5
self.updateStrip = function (stripWidth, wallHeight, stripIdx, wallType, distance, textureX, side, tileIndex, wallMapX, wallMapY) {
// self.x is now set in rayCasting for correct centering; do not set here
// Validate tileIndex (already a number)
if (tileIndex < 1 || tileIndex > 64 || !Number.isInteger(tileIndex)) {
console.error("RaycastStrip: Invalid tileIndex received: " + tileIndex + ". Clearing strip.");
self.clearStrip(); // Clear the strip if tileIndex is invalid
return;
}
// Use direct lookup instead of string concatenation
var newTileAssetName = wallTextureLookup[tileIndex];
// If the current sprite is active but not the correct type for the new tileIndex, release it.
// Note: We still use currentTileIndex (numeric) for the pool key, but use newTileAssetName for LK.getAsset if needed.
if (activeWallSprite && currentTileIndex !== tileIndex) {
// Compare numeric indices
wallSpritePoolManager.releaseSprite(activeWallSprite, currentTileIndex); // Pass numeric index
self.removeChild(activeWallSprite); // Detach from this strip's container
activeWallSprite = null;
currentTileIndex = null; // Clear current numeric index
}
// If no sprite is active (either first time, or after releasing/clearing), get one from the pool.
if (!activeWallSprite) {
// wallSpritePoolManager.getSprite now expects the numeric tileIndex and internally uses the lookup for asset name if creating new.
activeWallSprite = wallSpritePoolManager.getSprite(tileIndex); // Pass numeric index
if (activeWallSprite) {
self.addChild(activeWallSprite); // Add the new/reused sprite to this strip's container
currentTileIndex = tileIndex; // Store numeric index
} else {
// This can happen if wallSpritePoolManager.getSprite returns null
console.error("RaycastStrip: Failed to get sprite for tileIndex " + tileIndex + " from pool using asset name: " + newTileAssetName);
self.clearStrip(); // Ensure strip is clean if sprite acquisition fails
return; // Cannot render this strip without a sprite
}
}
// Configure the active sprite's properties (position, size, appearance)
activeWallSprite.width = stripWidth;
activeWallSprite.height = wallHeight;
// Position sprite relative to the RaycastStrip container's origin
activeWallSprite.x = 0;
activeWallSprite.y = (2732 - wallHeight) / 2; // Vertically center the wall segment
// Apply distance-based tinting
var SHADE_START_FACTOR_WALLS = 0.3; // Match floor/ceiling behavior
var MIN_SHADE_WALLS = 0.2; // General minimum shade for distant walls
var distanceRatio = distance / MAX_RENDER_DISTANCE;
var shadeFactor = Math.max(MIN_SHADE_WALLS, 1 - distanceRatio / SHADE_START_FACTOR_WALLS);
// --- Apply projectile light ---
var totalProjectileLightInfluence = 0;
var wallWorldCenterX = wallMapX + 0.5;
var wallWorldCenterY = wallMapY + 0.5;
for (var k = 0; k < projectiles.length; k++) {
var proj = projectiles[k];
if (proj.active) {
var dxProj = proj.worldX - wallWorldCenterX;
var dyProj = proj.worldY - wallWorldCenterY;
var distToProjectileSq = dxProj * dxProj + dyProj * dyProj; // Use squared distance for check
if (distToProjectileSq < PROJECTILE_LIGHT_RADIUS * PROJECTILE_LIGHT_RADIUS) {
var distToProjectile = Math.sqrt(distToProjectileSq);
var distRatioProj = distToProjectile / PROJECTILE_LIGHT_RADIUS;
var falloff = Math.pow(Math.max(0, 1 - distRatioProj), PROJECTILE_LIGHT_FALLOFF_EXPONENT);
totalProjectileLightInfluence += PROJECTILE_LIGHT_MAX_CONTRIBUTION * falloff;
}
}
}
shadeFactor = Math.min(1.0, shadeFactor + totalProjectileLightInfluence);
// --- End projectile light ---
if (side === 1) {
// Conventionally, side 1 walls (e.g., horizontal, further from light) are darker
shadeFactor *= 0.7; // Apply side shadow multiplicatively
// Ensure side shadow doesn't make it darker than a very low bound
shadeFactor = Math.max(MIN_SHADE_WALLS * 0.5, shadeFactor); // e.g. max(0.1, shadeFactor)
}
// Calculate tint based on shadeFactor
var baseWallTint = 0xFFFFFF; // Assuming walls are not pre-tinted by default in assets
var r_wall = baseWallTint >> 16 & 0xFF;
var g_wall = baseWallTint >> 8 & 0xFF;
var b_wall = baseWallTint & 0xFF;
r_wall = Math.floor(r_wall * shadeFactor);
g_wall = Math.floor(g_wall * shadeFactor);
b_wall = Math.floor(b_wall * shadeFactor);
activeWallSprite.tint = r_wall << 16 | g_wall << 8 | b_wall;
activeWallSprite.alpha = 1.0; // Use tint for darkness, keep alpha full for wall segments
activeWallSprite.visible = true; // Ensure the sprite is visible
};
// Clears the strip by releasing its active sprite back to the pool.
self.clearStrip = function () {
if (activeWallSprite && typeof currentTileIndex === 'number') {
// Check if currentTileIndex is valid
wallSpritePoolManager.releaseSprite(activeWallSprite, currentTileIndex); // Pass numeric index
self.removeChild(activeWallSprite); // Detach from container
activeWallSprite = null;
currentTileIndex = null;
}
// Optionally, could make the RaycastStrip container itself invisible:
// self.visible = false;
// But simply having no visible children often suffices.
};
// Note: LK's game reset mechanism (re-initializing Game class) typically handles
// destruction of game objects. If RaycastStrip instances were managed in a way
// that required manual cleanup of their sprites before game reset, a specific
// destroy method could be added here. For now, clearStrip and parent destruction
// should manage pooled sprites correctly.
return self;
});
var Treasure = Container.expand(function () {
var self = Container.call(this);
// Closed treasure sprite
var treasureSprite = self.attachAsset('treasure', {
anchorX: 0.5,
anchorY: 0.5
});
treasureSprite.tint = 0xdddddd; // Light grey tint
// Open treasure sprite (initially hidden)
var treasureOpenSprite = self.attachAsset('treasureopen', {
anchorX: 0.5,
anchorY: 0.5,
alpha: 0
});
self.treasureSprite = treasureSprite;
self.treasureOpenSprite = treasureOpenSprite;
self.isOpen = false;
self.mapX = 0;
self.mapY = 0;
self.value = 1;
self.openTreasure = function () {
if (!self.isOpen) {
self.isOpen = true;
self.treasureSprite.alpha = 0;
self.treasureOpenSprite.alpha = 1;
}
};
return self;
});
/****
* Initialize Game
****/
var game = new LK.Game({
backgroundColor: 0x111111
});
/****
* Game Code
****/
var dynamicEntitiesContainer;
// At start of game, create a texture lookup table
var wallTextureLookup = new Array(65);
for (var i = 1; i <= 64; i++) {
wallTextureLookup[i] = 'walltile' + i;
}
var wallSpritePoolManager = {
pools: function () {
// IIFE to pre-populate pools for each wall tile type, using numeric keys.
var p = {};
var PREALLOCATE_PER_TILE = 48; // Increased from 12 to reduce pop-in
for (var i = 1; i <= 64; i++) {
// Numeric index for pool key
var assetNameForLK = wallTextureLookup[i]; // Use lookup table for asset name
if (!assetNameForLK) {
console.error("WallSpritePoolManager (init pools): No asset name found in lookup for tileIndex: " + i);
continue; // Skip if asset name is not found
}
var tilePool = []; // Cache the array for this specific tile index
p[i] = tilePool; // Use numeric index i as the key
for (var j = 0; j < PREALLOCATE_PER_TILE; j++) {
var sprite = LK.getAsset(assetNameForLK, {
// Use string name from lookup for LK
anchorX: 0,
anchorY: 0,
visible: false
});
tilePool.push(sprite); // Push to the cached array
}
}
return p;
}(),
getSprite: function getSprite(tileIndex) {
// Parameter is now tileIndex (number)
// Validate tileIndex and ensure its pool exists
if (!this.pools[tileIndex]) {
var isValidTile = Number.isInteger(tileIndex) && tileIndex >= 1 && tileIndex <= 64;
if (isValidTile) {
this.pools[tileIndex] = []; // Create pool if valid but missing
console.warn("WallSpritePoolManager: Created pool on-the-fly for tile index: " + tileIndex);
} else {
console.error("WallSpritePoolManager: Attempted to get sprite for invalid tile index: " + tileIndex);
return null; // Invalid tile index
}
}
var pool = this.pools[tileIndex];
if (pool.length > 0) {
var sprite = pool.pop();
return sprite;
} else {
// Pool is empty, create a new sprite on demand
// Use the lookup table to get the asset name string
var assetNameForLK = wallTextureLookup[tileIndex];
if (!assetNameForLK) {
console.error("WallSpritePoolManager: No asset name found in lookup for tileIndex: " + tileIndex);
return null;
}
var newSprite = LK.getAsset(assetNameForLK, {
anchorX: 0,
anchorY: 0,
visible: false
});
return newSprite;
}
},
releaseSprite: function releaseSprite(sprite, tileIndex) {
// Parameter is now tileIndex (number)
if (!sprite || typeof tileIndex !== 'number') {
console.error("WallSpritePoolManager: Invalid sprite or tileIndex for release. tileIndex: " + tileIndex);
return;
}
// Validate tileIndex and ensure its pool exists for release
if (!this.pools[tileIndex]) {
var isValidTile = Number.isInteger(tileIndex) && tileIndex >= 1 && tileIndex <= 64;
if (isValidTile) {
this.pools[tileIndex] = []; // Create pool if valid but missing
console.warn("WallSpritePoolManager: Releasing sprite to a newly created pool for tile index: " + tileIndex);
} else {
console.error("WallSpritePoolManager: Attempted to release sprite for invalid tile index: " + tileIndex);
return;
}
}
// Sprite is assumed to be already removed from its parent by RaycastStrip.
// Thoroughly reset sprite properties to a clean state:
sprite.visible = false;
sprite.alpha = 1.0;
sprite.scale.set(1.0, 1.0);
sprite.rotation = 0;
sprite.x = 0;
sprite.y = 0;
sprite.tint = 0xFFFFFF; // Reset tint as it's now being used for shading
this.pools[tileIndex].push(sprite);
}
};
// Game constants
var PROJECTILE_LIGHT_RADIUS = 5.0; // World units for how far projectile light reaches
var PROJECTILE_LIGHT_MAX_CONTRIBUTION = 0.6; // Max brightness projectile can add (0 to 1)
var PROJECTILE_LIGHT_FALLOFF_EXPONENT = 2.0; // Power for light falloff (e.g., 2 for quadratic)
var MAP_SIZE = 16;
var PROJECTILE_WALL_HIT_PARTICLE_COUNT = 8; // Number of particles for projectile wall impact
var particlePoolManager;
var particleExplosionContainer; // Container for all active particles from explosions
var CELL_SIZE = 20;
var MINI_MAP_SCALE = 1;
var STRIP_WIDTH = 5; // Increase from 8 to 16 (half as many rays)
var NUM_RAYS = Math.ceil(2048 / STRIP_WIDTH);
// Pre-allocate ray result objects to avoid garbage collection
var rayResults = new Array(NUM_RAYS);
for (var i = 0; i < NUM_RAYS; i++) {
rayResults[i] = {
wallHit: false,
distance: 0,
// perpWallDist from DDA
actualDistance: 0,
// distance corrected for FOV, used for shading and strip height
side: 0,
wallType: 0,
mapX: 0,
// from DDA
mapY: 0,
// from DDA
screenX: 0,
// strip's screen X position
wallHeight: 0,
// calculated wall height
textureIndex: 0 // calculated texture/tile index
};
}
var FOV = Math.PI / 3; // 60 degrees field of view
// Variables for raycasting optimization
var lastPlayerX = -999;
var lastPlayerY = -999;
var lastPlayerDir = -999;
var shouldRaycast = true; // Ensure first frame does a full raycast
var HALF_FOV = FOV / 2;
var PLAYER_MOVE_SPEED = 0.002; // Reduced from 0.005 to make movement slower
var PLAYER_TURN_SPEED = 0.002; // Reduced from 0.005 to make turning slower
var WALL_HEIGHT_FACTOR = 700;
var MAX_RENDER_DISTANCE = 16;
var MONSTER_AGGRO_RANGE = 7.0; // Max distance at which monsters will notice and start chasing the player.
var MONSTER_CLOSE_PROXIMITY_RANGE = 4.0; // Max distance for non-LOS chase if within AGGRO_RANGE.
var MIN_MONSTER_PLAYER_DISTANCE = 0.6; // Monsters stop if they get closer than this (e.g., overshoot); this is within attack range.
var MAX_MONSTER_PLAYER_ENGAGEMENT_DISTANCE = 0.6;
var WALL_BUFFER = 0.18; // Minimum distance from player center to wall cell's effective edge
var MONSTER_WALL_BUFFER = 0.25; // Minimum distance from monster center to wall cell's effective edge
var MONSTER_COUNT = 8; // Increased by 50% from 5, rounded up
var TREASURE_COUNT = 10;
// Game state
var map = [];
var floorCaster;
var player = {
x: 1.5,
y: 1.5,
dir: 0,
health: 5,
// Current health
maxHealth: 5,
// Maximum health, starts at 5
score: 0,
level: 1,
// Permanent Stats Enhanced by Power-ups
moveSpeedMultiplier: 1.0,
// Base is 1.0, increases with speed power-ups
defenseBuffActive: false,
// Remains a boolean toggle, activated by defense power-up
attackPower: 1 // Base attack power is 1, increases with attack power-ups
// Buff timeouts are removed as effects are permanent
};
var powerUps = [];
var POWERUP_COUNT = 3; // Number of power-ups to spawn per level
// POWERUP_DURATION is no longer needed as buffs are permanent until new game
var controls = {
forward: false,
backward: false,
left: false,
right: false,
attack: false
};
var monsters = [];
var treasures = [];
var fountains = [];
var fountainPlacedThisLevel = false;
var projectiles = [];
var gate = null;
var wallSegments = [];
var lastWallCheck = [];
var lastTime = Date.now();
var canAttack = true;
var attackCooldown = 500; // 500 millisecond cooldown
// Joystick override state
var joystickOverrideActive = false;
var joystickOverrideX = 0;
var joystickOverrideY = 0;
// UI elements
var miniMap;
var rayCastView;
var healthText;
var scoreText;
var levelText;
var monsterText;
var controlButtons = {};
var playerMarker;
var globalRightHand; // Stores the right hand game object
var activeControlForGlobalHandlers = null; // Tracks which control is active for game.down/up/move
var renderableObjects = []; // Array to hold all objects (walls, entities) to be sorted by depth
// Create layer variables at the global scope
var gameLayer = new Container();
var projectileLayer = new Container();
var handLayer = new Container();
particlePoolManager = {
pool: [],
activeParticles: [],
maxParticles: 750,
// Increased from 300 to support more particles
// Increased max particles
getParticle: function getParticle() {
var particle;
if (this.pool.length > 0) {
particle = this.pool.pop();
} else {
particle = new Particle();
}
this.activeParticles.push(particle);
// The particle will be added to particleExplosionContainer by createParticleExplosion
return particle;
},
releaseParticle: function releaseParticle(particle) {
// Remove from activeParticles
var index = this.activeParticles.indexOf(particle);
if (index !== -1) {
this.activeParticles.splice(index, 1);
}
// Remove from its parent container (particleExplosionContainer)
if (particle.parent) {
particle.parent.removeChild(particle);
}
if (this.pool.length < this.maxParticles) {
this.pool.push(particle);
} else {
// If pool is full, LK will garbage collect if it has no parent.
// Explicitly calling destroy isn't standard in LK for pooled objects unless necessary.
}
},
updateActiveParticles: function updateActiveParticles() {
for (var i = this.activeParticles.length - 1; i >= 0; i--) {
var particle = this.activeParticles[i];
if (!particle.update()) {
// Particle.update returns false when life is over
this.releaseParticle(particle);
}
}
}
};
function createParticleExplosion(worldExplosionX, worldExplosionY, count, tintColor, isWallHit) {
if (!particleExplosionContainer) {
particleExplosionContainer = new Container();
projectileLayer.addChild(particleExplosionContainer);
}
var finalExplosionX = worldExplosionX;
var finalExplosionY = worldExplosionY;
var wallNormalX = 0;
var wallNormalY = 0;
var applyWallBias = isWallHit === true; // Explicitly check for true
if (applyWallBias) {
// Determine the wall normal direction to offset the particles away from the wall
var cellX = Math.floor(worldExplosionX);
var cellY = Math.floor(worldExplosionY);
// Check adjacent cells to determine wall direction
if (cellX + 1 < MAP_SIZE && map[cellY][cellX + 1].type === 1) {
wallNormalX = -1; // Wall is to the right, push left
} else if (cellX - 1 >= 0 && map[cellY][cellX - 1].type === 1) {
wallNormalX = 1; // Wall is to the left, push right
}
if (cellY + 1 < MAP_SIZE && map[cellY + 1][cellX].type === 1) {
wallNormalY = -1; // Wall is below, push up
} else if (cellY - 1 >= 0 && map[cellY - 1][cellX].type === 1) {
wallNormalY = 1; // Wall is above, push down
}
// If we're at a corner or couldn't determine direction, use distance from cell center
if (wallNormalX === 0 && wallNormalY === 0) {
wallNormalX = worldExplosionX - (cellX + 0.5);
wallNormalY = worldExplosionY - (cellY + 0.5);
var normalLength = Math.sqrt(wallNormalX * wallNormalX + wallNormalY * wallNormalY);
if (normalLength > 0) {
wallNormalX /= normalLength;
wallNormalY /= normalLength;
} else {
wallNormalX = -Math.cos(player.dir);
wallNormalY = -Math.sin(player.dir);
}
}
var offsetDistance = 0.1;
finalExplosionX = worldExplosionX + wallNormalX * offsetDistance;
finalExplosionY = worldExplosionY + wallNormalY * offsetDistance;
} // else, finalExplosionX/Y remain worldExplosionX/Y
var worldExplosionZ = 0.5; // Assume explosion originates roughly half a unit above the floor
for (var i = 0; i < count; i++) {
var particle = particlePoolManager.getParticle();
var phi = Math.random() * Math.PI * 2;
var theta = Math.acos(Math.random() * 2 - 1);
var speed = (Math.random() * 0.015 + 0.0075) * (player.level > 3 ? 1.5 : 1);
var vx = Math.sin(theta) * Math.cos(phi) * speed;
var vy = Math.sin(theta) * Math.sin(phi) * speed;
var vz = Math.cos(theta) * speed + 0.03;
if (applyWallBias) {
vx += wallNormalX * speed * 0.5;
vy += wallNormalY * speed * 0.5;
}
var life = 360 + Math.random() * 240;
particle.init(finalExplosionX, finalExplosionY, worldExplosionZ, vx, vy, vz, life, tintColor);
particle.collisionImmunity = applyWallBias ? 10 : 0; // Only give immunity for wall hits
particleExplosionContainer.addChild(particle);
}
}
// Setup game
function setupGame() {
// Set up the layer system using the globally defined variables
// Add layers in the correct order (projectiles below hand)
game.addChild(gameLayer);
game.addChild(projectileLayer);
game.addChild(handLayer);
// Create the floor/ceiling caster first (should be below walls)
floorCaster = new FloorCaster();
gameLayer.addChild(floorCaster);
// Create the rayCast view container
// rayCastView = new Container(); // rayCastView is no longer used to hold strips directly
// gameLayer.addChild(rayCastView); // No longer add rayCastView if it's not used for strips
// Initialize wallSegments array to hold RaycastStrip instances
wallSegments = [];
// Create raycast strips
for (var i = 0; i < NUM_RAYS; i++) {
var strip = new RaycastStrip();
wallSegments.push(strip); // Store strips in an array, not in a display container yet
}
// Initialize container for depth-sorted dynamic entities (walls and entities)
dynamicEntitiesContainer = new Container();
gameLayer.addChild(dynamicEntitiesContainer);
// Create minimap container
miniMap = new Container();
// Move mini map left by 25% of its original centered position
miniMap.x = (2048 - MAP_SIZE * CELL_SIZE * MINI_MAP_SCALE) / 2 * 0.40;
miniMap.y = 40; // Keep at top
gameLayer.addChild(miniMap);
// Generate map
generateMap();
// Create player marker
playerMarker = gameLayer.addChild(LK.getAsset('player', {
anchorX: 0.5,
anchorY: 0.5
}));
// Create UI elements
createUI();
// Create control buttons
createControlButtons();
// Start background music
LK.playMusic('dungeon');
}
function generateMap() {
// Clear existing map visuals, data structures
miniMap.removeChildren();
map = [];
// Remove existing monsters and treasures
for (var i = 0; i < monsters.length; i++) {
monsters[i].destroy();
}
monsters = [];
for (var i = 0; i < treasures.length; i++) {
treasures[i].destroy();
}
treasures = [];
// Clear projectiles
for (var i = 0; i < projectiles.length; i++) {
projectiles[i].destroy();
}
projectiles = [];
// Clear existing powerups
for (var i = 0; i < powerUps.length; i++) {
powerUps[i].destroy();
}
powerUps = [];
// Clear existing fountains
for (var i = 0; i < fountains.length; i++) {
if (fountains[i].parent) {
fountains[i].parent.removeChild(fountains[i]);
}
fountains[i].destroy();
}
fountains = [];
fountainPlacedThisLevel = false; // Reset flag for the new level
// --- Map type selection ---
// 0 = maze, 1 = arena with pillars, 2 = mixed, 3 = two-rooms-grid
var mapType = Math.floor(Math.random() * 4);
// 1. Initialize the map: all cells are walls
for (var y = 0; y < MAP_SIZE; y++) {
map[y] = [];
for (var x = 0; x < MAP_SIZE; x++) {
var cell = new MapCell();
cell.x = x * CELL_SIZE * MINI_MAP_SCALE;
cell.y = y * CELL_SIZE * MINI_MAP_SCALE;
cell.setType(1); // Initialize as wall
map[y][x] = cell;
miniMap.addChild(cell); // Add to minimap
}
}
// --- Maze type ---
if (mapType === 0) {
// 2. Implement Recursive Backtracker (a form of Growing Tree) for maze generation
var stack = [];
var startX = 1; // Player's typical start X
var startY = 1; // Player's typical start Y
map[startY][startX].setType(0); // Mark starting cell as floor
stack.push({
x: startX,
y: startY
});
while (stack.length > 0) {
var current = stack[stack.length - 1]; // Get current cell (peek)
var potentialMoves = [{
dx: 0,
dy: -2,
wallXOffset: 0,
wallYOffset: -1
}, {
dx: 2,
dy: 0,
wallXOffset: 1,
wallYOffset: 0
}, {
dx: 0,
dy: 2,
wallXOffset: 0,
wallYOffset: 1
}, {
dx: -2,
dy: 0,
wallXOffset: -1,
wallYOffset: 0
}];
// Shuffle potential moves to ensure randomness
for (var i = potentialMoves.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = potentialMoves[i];
potentialMoves[i] = potentialMoves[j];
potentialMoves[j] = temp;
}
var moved = false;
for (var i = 0; i < potentialMoves.length; i++) {
var move = potentialMoves[i];
var nextX = current.x + move.dx;
var nextY = current.y + move.dy;
var wallBetweenX = current.x + move.wallXOffset;
var wallBetweenY = current.y + move.wallYOffset;
if (nextX >= 0 && nextX < MAP_SIZE && nextY >= 0 && nextY < MAP_SIZE && map[nextY][nextX].type === 1) {
map[wallBetweenY][wallBetweenX].setType(0);
map[nextY][nextX].setType(0);
stack.push({
x: nextX,
y: nextY
});
moved = true;
break;
}
}
if (!moved) {
stack.pop();
}
}
// 3. Explicitly set outer border walls.
for (y = 0; y < MAP_SIZE; y++) {
if (map[y][0].type === 0 && !(y === startY && startX === 0)) {
map[y][0].setType(1);
}
if (map[y][MAP_SIZE - 1].type === 0 && !(y === startY && startX === MAP_SIZE - 1)) {
map[y][MAP_SIZE - 1].setType(1);
}
}
for (x = 0; x < MAP_SIZE; x++) {
if (map[0][x].type === 0 && !(x === startX && startY === 0)) {
map[0][x].setType(1);
}
if (map[MAP_SIZE - 1][x].type === 0 && !(x === startX && startY === MAP_SIZE - 1)) {
map[MAP_SIZE - 1][x].setType(1);
}
}
map[startY][startX].setType(0);
ensureMapConnectivity();
} else if (mapType === 1) {
// --- Arena with pillars ---
// Clear a large open area in the center
var margin = 2;
for (var y = margin; y < MAP_SIZE - margin; y++) {
for (var x = margin; x < MAP_SIZE - margin; x++) {
map[y][x].setType(0);
}
}
// Place pillars in a grid pattern
for (var py = margin + 2; py < MAP_SIZE - margin - 1; py += 3) {
for (var px = margin + 2; px < MAP_SIZE - margin - 1; px += 3) {
map[py][px].setType(1);
}
}
// Player start near bottom left
// player.x = margin + 1.5; // Player position will be set later
// player.y = margin + 1.5; // Player position will be set later
// player.dir = 0;//{8K} // Player position will be set later
} else if (mapType === 3) {
// --- Grid of corridors with a small room in the center ---
var gridSpacing = 3; // Defines the spacing of the corridors
// Create horizontal corridors
for (var gy = 1; gy < MAP_SIZE - 1; gy++) {
if (gy % gridSpacing === 1) {
// Place a corridor if gy is 1, 4, 7... (for gridSpacing = 3)
for (var x = 1; x < MAP_SIZE - 1; x++) {
map[gy][x].setType(0);
}
}
}
// Create vertical corridors
for (var gx = 1; gx < MAP_SIZE - 1; gx++) {
if (gx % gridSpacing === 1) {
// Place a corridor if gx is 1, 4, 7... (for gridSpacing = 3)
for (var y = 1; y < MAP_SIZE - 1; y++) {
map[y][gx].setType(0);
}
}
}
// Create a small central room
var centralRoomSize = 3; // Size of the central room (e.g., 3x3)
var roomStartX = Math.floor(MAP_SIZE / 2) - Math.floor(centralRoomSize / 2);
var roomStartY = Math.floor(MAP_SIZE / 2) - Math.floor(centralRoomSize / 2);
for (var y_room = roomStartY; y_room < roomStartY + centralRoomSize; y_room++) {
for (var x_room = roomStartX; x_room < roomStartX + centralRoomSize; x_room++) {
// Ensure room coordinates are within the carveable map area
if (x_room >= 1 && x_room < MAP_SIZE - 1 && y_room >= 1 && y_room < MAP_SIZE - 1) {
map[y_room][x_room].setType(0);
}
}
}
// 4. Ensure all parts of the map are connected after generation (for all types)
ensureMapConnectivity();
} else {
// --- Mixed: maze with open arena center and some pillars ---
// First, generate a maze as in type 0
var stack = [];
var startX = 1;
var startY = 1;
map[startY][startX].setType(0);
stack.push({
x: startX,
y: startY
});
while (stack.length > 0) {
var current = stack[stack.length - 1];
var potentialMoves = [{
dx: 0,
dy: -2,
wallXOffset: 0,
wallYOffset: -1
}, {
dx: 2,
dy: 0,
wallXOffset: 1,
wallYOffset: 0
}, {
dx: 0,
dy: 2,
wallXOffset: 0,
wallYOffset: 1
}, {
dx: -2,
dy: 0,
wallXOffset: -1,
wallYOffset: 0
}];
for (var i = potentialMoves.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = potentialMoves[i];
potentialMoves[i] = potentialMoves[j];
potentialMoves[j] = temp;
}
var moved = false;
for (var i = 0; i < potentialMoves.length; i++) {
var move = potentialMoves[i];
var nextX = current.x + move.dx;
var nextY = current.y + move.dy;
var wallBetweenX = current.x + move.wallXOffset;
var wallBetweenY = current.y + move.wallYOffset;
if (nextX >= 0 && nextX < MAP_SIZE && nextY >= 0 && nextY < MAP_SIZE && map[nextY][nextX].type === 1) {
map[wallBetweenY][wallBetweenX].setType(0);
map[nextY][nextX].setType(0);
stack.push({
x: nextX,
y: nextY
});
moved = true;
break;
}
}
if (!moved) {
stack.pop();
}
}
// Open up a central arena
var arenaMargin = 4;
for (var y = arenaMargin; y < MAP_SIZE - arenaMargin; y++) {
for (var x = arenaMargin; x < MAP_SIZE - arenaMargin; x++) {
map[y][x].setType(0);
}
}
// Add a few random pillars in the arena
for (var p = 0; p < 8; p++) {
var px = Math.floor(Math.random() * (MAP_SIZE - 2 * arenaMargin - 2)) + arenaMargin + 1;
var py = Math.floor(Math.random() * (MAP_SIZE - 2 * arenaMargin - 2)) + arenaMargin + 1;
map[py][px].setType(1);
}
// Player start near bottom left
// player.x = arenaMargin + 1.5; // Player position will be set later
// player.y = arenaMargin + 1.5; // Player position will be set later
// player.dir = 0;//{9M} // Player position will be set later
}
// 4. Ensure all parts of the map are connected after generation (for all types)
ensureMapConnectivity();
// --- Player Spawn Logic ---
var intendedPlayerX, intendedPlayerY;
if (mapType === 0) {
// Maze
intendedPlayerX = 1.5;
intendedPlayerY = 1.5;
} else if (mapType === 1) {
// Arena
var margin = 2; // Variable 'margin' is defined in the Arena mapType block
intendedPlayerX = margin + 1.5;
intendedPlayerY = margin + 1.5;
} else if (mapType === 2) {
// Explicitly mapType === 2 for Mixed
// Mixed
var arenaMargin = 4; // Variable 'arenaMargin' is defined in the Mixed mapType block
intendedPlayerX = arenaMargin + 1.5;
intendedPlayerY = arenaMargin + 1.5;
} else if (mapType === 3) {
// New case for mapType === 3
// Grid with central room - spawn in the center of the map (likely in the central room)
intendedPlayerX = Math.floor(MAP_SIZE / 2) + 0.5;
intendedPlayerY = Math.floor(MAP_SIZE / 2) + 0.5;
}
var spawnIsValid = false;
// Check if intendedPlayerX/Y are within map cell indices for array access
var intendedCellX = Math.floor(intendedPlayerX);
var intendedCellY = Math.floor(intendedPlayerY);
if (intendedCellX >= 0 && intendedCellX < MAP_SIZE && intendedCellY >= 0 && intendedCellY < MAP_SIZE) {
if (map[intendedCellY][intendedCellX].type === 0 && getDistanceToNearestWall(intendedPlayerX, intendedPlayerY) >= WALL_BUFFER) {
spawnIsValid = true;
player.x = intendedPlayerX;
player.y = intendedPlayerY;
}
}
if (!spawnIsValid) {
console.warn("Intended player spawn point was invalid. Searching for an alternative...");
var alternativeSpawns = [];
// Search the entire map, excluding borders, for a valid spawn point
for (var y_scan = 1; y_scan < MAP_SIZE - 1; y_scan++) {
for (var x_scan = 1; x_scan < MAP_SIZE - 1; x_scan++) {
var currentSpawnCandidateX = x_scan + 0.5;
var currentSpawnCandidateY = y_scan + 0.5;
if (map[y_scan][x_scan].type === 0 && getDistanceToNearestWall(currentSpawnCandidateX, currentSpawnCandidateY) >= WALL_BUFFER) {
alternativeSpawns.push({
x: currentSpawnCandidateX,
y: currentSpawnCandidateY
});
}
}
}
if (alternativeSpawns.length > 0) {
var randomIndex = Math.floor(Math.random() * alternativeSpawns.length);
player.x = alternativeSpawns[randomIndex].x;
player.y = alternativeSpawns[randomIndex].y;
console.log("Player spawn adjusted to a valid alternative: (" + player.x + ", " + player.y + ")");
} else {
// This is a critical failure case - no valid spawn point found anywhere.
// Default to a common, possibly unsafe, point and log an error.
console.error("CRITICAL: No valid player spawn point found anywhere in the map. Defaulting to (1.5, 1.5). Player may be stuck.");
player.x = 1.5;
player.y = 1.5;
}
}
player.dir = 0; // Ensure player direction is reset
// Ensure player doesn't start facing a wall
var initialPlayerDirForRotationCheck = player.dir; // Store initial to potentially revert
var playerRotationAttempts = 0;
while (playerRotationAttempts < 4) {
// Max 4 attempts for 0, 90, 180, 270 degrees
var lookAheadDist = 1.0; // Check one cell ahead
var cellInFrontMapX = Math.floor(player.x + Math.cos(player.dir) * lookAheadDist);
var cellInFrontMapY = Math.floor(player.y + Math.sin(player.dir) * lookAheadDist);
// Check if the cell in front is within map boundaries and is a floor tile
if (cellInFrontMapX >= 0 && cellInFrontMapX < MAP_SIZE && cellInFrontMapY >= 0 && cellInFrontMapY < MAP_SIZE && map[cellInFrontMapY] && map[cellInFrontMapY][cellInFrontMapX] && map[cellInFrontMapY][cellInFrontMapX].type === 0) {
// Found an open space to face
break;
}
// If facing a wall or out of bounds, rotate player by 90 degrees clockwise
player.dir += Math.PI / 2;
// Normalize direction to be within [0, 2*PI)
if (player.dir >= Math.PI * 2) {
player.dir -= Math.PI * 2;
}
playerRotationAttempts++;
}
if (playerRotationAttempts === 4) {
console.warn("Player could not be rotated to face an open space after 4 attempts. Reverting to original direction: " + initialPlayerDirForRotationCheck);
player.dir = initialPlayerDirForRotationCheck;
// A more robust fallback could be to find ANY open adjacent cell and face it,
// but for now, reverting or keeping the last attempted direction is acceptable.
}
// --- End Player Spawn Logic ---
// Create monsters
var monstersToPlace = MONSTER_COUNT + Math.floor(player.level * 0.5);
for (var i = 0; i < monstersToPlace; i++) {
placeMonster();
}
// Create treasures
placeTreasure(); // Call once to place 1-3 treasures in total per map
// Place exit gate
gate = placeGate();
// Place one fountain
placeFountain();
// Power-ups are now only spawned from opened treasures, not during map generation
}
function placePowerUp() {
var x, y;
var attempts = 0;
var powerUpTypes = ['speed', 'defense', 'health', 'attack'];
var randomType = powerUpTypes[Math.floor(Math.random() * powerUpTypes.length)];
do {
x = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1;
y = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1;
attempts++;
if (attempts > 100) {
break;
}
} while (map[y][x].type !== 0 || map[y][x].monster || map[y][x].treasure || map[y][x].gate || mapCellHasPowerUp(x, y));
if (attempts <= 100) {
var powerUp = new PowerUp(randomType);
powerUp.mapX = x + 0.5; // Center in cell
powerUp.mapY = y + 0.5; // Center in cell
powerUps.push(powerUp);
dynamicEntitiesContainer.addChild(powerUp);
}
}
function placeFountain() {
if (fountainPlacedThisLevel) {
return;
}
var potentialSpots = [];
var deadEndSpots = [];
for (var y = 1; y < MAP_SIZE - 1; y++) {
for (var x = 1; x < MAP_SIZE - 1; x++) {
if (map[y] && map[y][x] && map[y][x].type === 0 && !map[y][x].monster && !map[y][x].treasure && !map[y][x].gate) {
// Check if already a fountain here by iterating fountains array
var alreadyFountain = false;
for (var fIdx = 0; fIdx < fountains.length; fIdx++) {
if (Math.floor(fountains[fIdx].mapX) === x && Math.floor(fountains[fIdx].mapY) === y) {
alreadyFountain = true;
break;
}
}
if (!alreadyFountain) {
potentialSpots.push({
x: x,
y: y
});
}
}
}
}
if (potentialSpots.length === 0) {
return;
}
for (var i = 0; i < potentialSpots.length; i++) {
var spot = potentialSpots[i];
var openNeighbors = 0;
var neighbors = [{
dx: 0,
dy: -1
}, {
dx: 1,
dy: 0
}, {
dx: 0,
dy: 1
}, {
dx: -1,
dy: 0
}];
for (var j = 0; j < neighbors.length; j++) {
var nx = spot.x + neighbors[j].dx;
var ny = spot.y + neighbors[j].dy;
if (nx >= 0 && nx < MAP_SIZE && ny >= 0 && ny < MAP_SIZE && map[ny] && map[ny][nx] && map[ny][nx].type === 0) {
openNeighbors++;
}
}
if (openNeighbors === 1) {
deadEndSpots.push(spot);
}
}
if (deadEndSpots.length === 0) {
return;
}
// Shuffle deadEndSpots
for (var i = deadEndSpots.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = deadEndSpots[i];
deadEndSpots[i] = deadEndSpots[j];
deadEndSpots[j] = temp;
}
var placed = false;
for (var k = 0; k < deadEndSpots.length; k++) {
var spotToPlace = deadEndSpots[k];
var cellX = spotToPlace.x;
var cellY = spotToPlace.y;
var isPlayerSpot = cellX === Math.floor(player.x) && cellY === Math.floor(player.y);
// Gate might not be placed yet if this is called before placeGate, so check gate object directly
var isGateSpot = gate && cellX === Math.floor(gate.mapX) && cellY === Math.floor(gate.mapY);
// Re-check map cell properties directly, as they are the source of truth for monsters/treasures placed by their respective functions
var cellIsOccupiedByOtherMapEntity = map[cellY][cellX].monster || map[cellY][cellX].treasure || map[cellY][cellX].gate;
if (!isPlayerSpot && !isGateSpot && !cellIsOccupiedByOtherMapEntity && map[cellY][cellX].type === 0) {
var fountain = new Fountain();
fountain.init(cellX + 0.5, cellY + 0.5);
fountains.push(fountain);
dynamicEntitiesContainer.addChild(fountain); // Add to the same container as monsters/treasures
fountainPlacedThisLevel = true;
placed = true;
break;
}
}
}
function mapCellHasPowerUp(cellX, cellY) {
for (var i = 0; i < powerUps.length; i++) {
if (Math.floor(powerUps[i].mapX) === cellX && Math.floor(powerUps[i].mapY) === cellY) {
return true;
}
}
return false;
}
function placeMonster() {
// Find a random empty cell
var x, y;
var attempts = 0;
do {
x = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1;
y = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1;
attempts++;
// Make sure it's not too close to the player
var distToPlayer = Math.sqrt(Math.pow(x - player.x, 2) + Math.pow(y - player.y, 2));
if (attempts > 100) {
break;
} // Prevent infinite loop
} while (map[y][x].type !== 0 || map[y][x].monster || map[y][x].treasure || distToPlayer < 3);
if (attempts <= 100) {
map[y][x].addMonster();
var monster;
// Randomly decide to spawn a regular Monster, an EyeballMonster, or an ImpMonster
var randMonsterType = Math.random();
if (randMonsterType < 0.1) {
// 50% chance for Ogre (Monster)
monster = new Monster();
// Monster health is set in its constructor
} else if (randMonsterType < 0.4) {
// 30% chance for EyeballMonster (0.5 to 0.799...)
monster = new EyeballMonster();
// EyeballMonster health is set in its constructor
// monster.startBobbing(); // Call removed as bobbing is disabled
} else {
// 20% chance for ImpMonster (0.8 to 0.999...)
monster = new ImpMonster();
// ImpMonster health and other stats are set in its constructor
}
// Common monster setup
monster.mapX = x + 0.5; // Center in cell
monster.mapY = y + 0.5; // Center in cell
// Initialize attack cooldown properties for all monster types
// These might be defined in individual monster classes but ensuring they exist for MonsterAI
if (monster.attackCooldown === undefined) {
monster.attackCooldown = 2000;
} // Default if not set
if (monster.lastAttackTime === undefined) {
monster.lastAttackTime = 0;
}
if (monster.canAttack === undefined) {
monster.canAttack = true;
}
monsters.push(monster);
dynamicEntitiesContainer.addChild(monster);
}
}
function placeTreasure() {
var potentialSpots = [];
var deadEndSpots = [];
// Iterate through the map (excluding borders initially for simplicity in dead-end logic)
// to find all valid floor cells for treasure.
for (var y = 1; y < MAP_SIZE - 1; y++) {
for (var x = 1; x < MAP_SIZE - 1; x++) {
if (map[y] && map[y][x] && map[y][x].type === 0 && !map[y][x].monster && !map[y][x].treasure &&
// Check if cell already has a treasure (though addTreasure also checks)
!map[y][x].gate) {
potentialSpots.push({
x: x,
y: y
});
}
}
}
if (potentialSpots.length === 0) {
return; // No suitable spots found
}
// Identify dead ends from the list of potential spots
for (var i = 0; i < potentialSpots.length; i++) {
var spot = potentialSpots[i];
var openNeighbors = 0;
var neighbors = [{
dx: 0,
dy: -1
},
// North
{
dx: 1,
dy: 0
},
// East
{
dx: 0,
dy: 1
},
// South
{
dx: -1,
dy: 0
} // West
];
for (var j = 0; j < neighbors.length; j++) {
var nx = spot.x + neighbors[j].dx;
var ny = spot.y + neighbors[j].dy;
// Check bounds and if neighbor is a floor cell
if (nx >= 0 && nx < MAP_SIZE && ny >= 0 && ny < MAP_SIZE && map[ny] && map[ny][nx] && map[ny][nx].type === 0) {
openNeighbors++;
}
}
if (openNeighbors === 1) {
deadEndSpots.push(spot);
}
}
// Only spawn treasures in dead ends, and limit to 1-3 per map (total)
var maxTreasures = 1 + Math.floor(Math.random() * 3); // 1 to 3 treasures
if (deadEndSpots.length === 0) {
return; // No dead ends found, do not spawn treasure
}
if (deadEndSpots.length > 1) {
// Shuffle deadEndSpots
for (var i = deadEndSpots.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = deadEndSpots[i];
deadEndSpots[i] = deadEndSpots[j];
deadEndSpots[j] = temp;
}
}
var treasuresPlaced = 0;
for (var i = 0; i < deadEndSpots.length && treasuresPlaced < maxTreasures; i++) {
var spot = deadEndSpots[i];
var cellX = spot.x;
var cellY = spot.y;
// Check if the spot is the player's current location
if (cellX === Math.floor(player.x) && cellY === Math.floor(player.y)) {
continue; // Skip this spot if it's where the player is
}
// Double-check if we can add treasure to this cell (MapCell's own logic)
if (map[cellY] && map[cellY][cellX] && map[cellY][cellX].addTreasure()) {
var treasure = new Treasure();
treasure.mapX = cellX + 0.5; // Center treasure in the cell
treasure.mapY = cellY + 0.5; // Center treasure in the cell
treasure.value = 1 + Math.floor(Math.random() * player.level);
treasures.push(treasure);
dynamicEntitiesContainer.addChild(treasure);
treasuresPlaced++;
}
}
}
function placeGate() {
// Place gate in a random valid location
var x, y;
var attempts = 0;
var validPositions = [];
// Collect all valid positions first
for (var i = 0; i < 100; i++) {
x = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1;
y = Math.floor(Math.random() * (MAP_SIZE - 2)) + 1;
// Check if the cell is suitable
if (map[y][x].type === 0 && !map[y][x].monster && !map[y][x].treasure && !map[y][x].gate) {
// Make sure it's not too close to the player (at least 2 cells away)
var distToPlayer = Math.sqrt(Math.pow(x - player.x, 2) + Math.pow(y - player.y, 2));
if (distToPlayer > 2) {
// Add to valid positions
validPositions.push({
x: x,
y: y
});
}
}
}
// If we found valid spots, choose one randomly
if (validPositions.length > 0) {
// Pick a random position from the valid ones
var randomIndex = Math.floor(Math.random() * validPositions.length);
var chosenPos = validPositions[randomIndex];
var gateX = chosenPos.x;
var gateY = chosenPos.y;
// Place the gate
map[gateY][gateX].addGate();
var gate = new Gate();
gate.mapX = gateX;
gate.mapY = gateY;
dynamicEntitiesContainer.addChild(gate);
return gate;
}
return null;
}
function createUI() {
// Health display
healthText = new Text2('Health: ' + player.health, {
size: 40,
fill: 0xFF5555
});
healthText.anchor.set(0, 0);
LK.gui.topRight.addChild(healthText);
healthText.x = -200;
healthText.y = 20;
// Score display
scoreText = new Text2('Score: ' + player.score, {
size: 40,
fill: 0xFFFF55
});
scoreText.anchor.set(0, 0);
LK.gui.topRight.addChild(scoreText);
scoreText.x = -200;
scoreText.y = 80;
// Level display
levelText = new Text2('Level: ' + player.level, {
size: 40,
fill: 0x55FF55
});
levelText.anchor.set(0, 0);
LK.gui.topRight.addChild(levelText);
levelText.x = -200;
levelText.y = 140;
// Monster counter display
monsterText = new Text2('Monsters: 0', {
size: 40,
fill: 0xFF9955
});
monsterText.anchor.set(0, 0);
LK.gui.topRight.addChild(monsterText);
monsterText.x = -200;
monsterText.y = 200;
// Update UI displays
updateUI();
}
function updateUI() {
healthText.setText('Health: ' + player.health);
scoreText.setText('Score: ' + player.score);
levelText.setText('Level: ' + player.level);
monsterText.setText('Monsters: ' + monsters.length);
// Update score in LK system
LK.setScore(player.score);
}
function createControlButtons() {
// Create joystick control for movement
controlButtons.joystick = new JoystickController();
controlButtons.joystick.x = 400;
controlButtons.joystick.y = 2732 - 500;
gameLayer.addChild(controlButtons.joystick);
// Using the global layer system - no need to recreate layers here
// Create attack button more centered on the right side
controlButtons.attack = new ControlButton('attack');
controlButtons.attack.x = 2048 - 400;
controlButtons.attack.y = 2732 - 500;
gameLayer.addChild(controlButtons.attack);
// Add right hand at the bottom right for projectile firing
globalRightHand = LK.getAsset('rightHand', {
anchorX: 0.5,
anchorY: 0.9,
scaleX: 1.2,
scaleY: 1.2
});
globalRightHand.x = 2048 * 0.6; // Position just off right of middle
globalRightHand.y = 2732; // Bottom of the screen
handLayer.addChild(globalRightHand);
// Add pulse and float animation to give the hand some life
var _animateHand = function animateHand() {
// Get a small random x offset between -40 and 40 pixels
var randomXOffset = Math.random() * 80 - 40;
// Store original X position if not set yet
if (globalRightHand.originalX === undefined) {
globalRightHand.originalX = globalRightHand.x;
}
// Slow scale pulse animation with random left/right movement
tween(globalRightHand, {
scaleX: 1.1,
scaleY: 1.1,
x: globalRightHand.originalX + randomXOffset,
y: 2732 - 15 // Float up slightly from base position
}, {
duration: 1800,
easing: tween.easeInOut,
onFinish: function onFinish() {
// Get another random x offset for the return animation
var returnRandomXOffset = Math.random() * 40 - 20;
tween(globalRightHand, {
scaleX: 1.1,
scaleY: 1.1,
x: globalRightHand.originalX + returnRandomXOffset,
y: 2732 // Return to original position
}, {
duration: 1800,
easing: tween.easeInOut,
onFinish: _animateHand
});
}
});
};
// Start the hand animation
_animateHand();
}
function castRayDDAInto(startX, startY, rayDirX, rayDirY, result) {
// Pre-compute map coordinates only once
var mapX = Math.floor(startX);
var mapY = Math.floor(startY);
// Early bounds check to avoid unnecessary computation
if (mapX < 0 || mapX >= MAP_SIZE || mapY < 0 || mapY >= MAP_SIZE) {
result.wallHit = true;
result.distance = MAX_RENDER_DISTANCE;
result.side = 0;
result.wallType = 1; // Default wall type if out of bounds
result.mapX = mapX;
result.mapY = mapY;
return;
}
// Pre-compute these reciprocals once to avoid division in the loop
var deltaDistX = Math.abs(rayDirX) < 0.0001 ? 9999999 : Math.abs(1 / rayDirX);
var deltaDistY = Math.abs(rayDirY) < 0.0001 ? 9999999 : Math.abs(1 / rayDirY);
var stepX, stepY, sideDistX, sideDistY;
// Optimize direction calculations with ternary operators
stepX = rayDirX < 0 ? -1 : 1;
sideDistX = rayDirX < 0 ? (startX - mapX) * deltaDistX : (mapX + 1.0 - startX) * deltaDistX;
stepY = rayDirY < 0 ? -1 : 1;
sideDistY = rayDirY < 0 ? (startY - mapY) * deltaDistY : (mapY + 1.0 - startY) * deltaDistY;
// Use a maximum iteration count to prevent potential infinite loops
var hit = false;
var side = 0;
var maxIterations = MAP_SIZE * 2; // Maximum map traversal
var iterations = 0;
// Main DDA loop with iteration limit
while (!hit && iterations < maxIterations) {
// Store the smaller of the two side distances for optimization
if (sideDistX < sideDistY) {
sideDistX += deltaDistX;
mapX += stepX;
side = 0;
} else {
sideDistY += deltaDistY;
mapY += stepY;
side = 1;
}
// Fast bounds check
if (mapX < 0 || mapX >= MAP_SIZE || mapY < 0 || mapY >= MAP_SIZE) {
hit = true; // Ray went out of bounds
continue;
}
// Fast wall check - avoid accessing map if possible
if (map[mapY] && map[mapY][mapX] && map[mapY][mapX].type === 1) {
hit = true;
}
iterations++;
}
// Calculate distance precisely for the current ray hit
var perpWallDist;
if (hit) {
// Only calculate precise distance if a wall was hit (or went out of bounds)
if (side === 0) {
perpWallDist = (mapX - startX + (1 - stepX) / 2) / rayDirX;
} else {
perpWallDist = (mapY - startY + (1 - stepY) / 2) / rayDirY;
}
// Clamp distance
perpWallDist = Math.max(0.001, Math.min(MAX_RENDER_DISTANCE, perpWallDist));
} else {
// No wall hit within maxIterations, treat as max distance
perpWallDist = MAX_RENDER_DISTANCE;
}
result.wallHit = hit;
result.distance = perpWallDist;
result.side = side;
// Determine wallType only if hit and within bounds
if (hit && mapX >= 0 && mapX < MAP_SIZE && mapY >= 0 && mapY < MAP_SIZE && map[mapY] && map[mapY][mapX]) {
result.wallType = map[mapY][mapX].type;
} else {
result.wallType = 1; // Default or out-of-bounds wall type
}
result.mapX = mapX;
result.mapY = mapY;
}
// Separate the raycasting logic from the visual updates
function fullRayCasting() {
floorCaster.update(player.x, player.y, player.dir);
// Pre-calculate values needed for all rays
var playerX = player.x;
var playerY = player.y;
var playerDir = player.dir;
// Process all rays at once
for (var rayIdx = 0; rayIdx < NUM_RAYS; rayIdx++) {
var rayResult = rayResults[rayIdx]; // Use pre-allocated object
// Calculate ray angle and direction
var rayAngle = playerDir - HALF_FOV + rayIdx / NUM_RAYS * FOV;
var rayDirX = Math.cos(rayAngle);
var rayDirY = Math.sin(rayAngle);
// Get ray hit info - populates rayResult
castRayDDAInto(playerX, playerY, rayDirX, rayDirY, rayResult);
// rayResult.distance is perpWallDist
if (rayResult.wallHit) {
// Calculate final strip properties and store in rayResult
rayResult.actualDistance = rayResult.distance * Math.cos(rayAngle - playerDir);
rayResult.wallHeight = Math.max(STRIP_WIDTH, Math.min(2732, WALL_HEIGHT_FACTOR / rayResult.actualDistance));
var wallX;
if (rayResult.side === 0) {
wallX = playerY + rayResult.distance * rayDirY;
} else {
wallX = playerX + rayResult.distance * rayDirX;
}
var texturePos = wallX * 64; // This scales wallX so one unit of wallX effectively spans 64 original texture slices.
// New logic for selecting from slices 6-35 repeating
// Original calculation provided an index from 0 to 63 for a 64-slice block.
var baseIndexOriginal64 = Math.floor(texturePos) % 64;
var numNewSlices = 30; // Slices 6 through 35 inclusive (35 - 6 + 1 = 30 textures)
// Map the 0-63 original index to a 0-29 index for our new 30-slice repeating block.
var newSliceIndexInRepeatingBlock = baseIndexOriginal64 % numNewSlices;
// Offset this 0-29 index to match the desired range of 6-35.
var tileIndex = 6 + newSliceIndexInRepeatingBlock; // tileIndex will be from 6 to 35 (e.g. walltile6 to walltile35)
// Apply reflection based on side and ray direction, similar to original.
// The reflection should map within the new range [6, 35].
var minTexIdNewRange = 6;
var maxTexIdNewRange = 35;
if (rayResult.side === 0 && rayDirX > 0) {
// Reflects: e.g., if min=6, max=35: 6 maps to 35, 7 to 34, ..., 35 to 6.
// Formula: new_min + (new_max - current_value_in_new_range)
tileIndex = minTexIdNewRange + (maxTexIdNewRange - tileIndex);
}
if (rayResult.side === 1 && rayDirY < 0) {
// Same reflection logic for this case.
tileIndex = minTexIdNewRange + (maxTexIdNewRange - tileIndex);
}
rayResult.textureIndex = tileIndex;
rayResult.screenX = Math.round(rayIdx * STRIP_WIDTH + (2048 - NUM_RAYS * STRIP_WIDTH) / 2);
// Update strip with calculated values
var strip = wallSegments[rayIdx];
strip.x = rayResult.screenX;
strip.updateStrip(STRIP_WIDTH, rayResult.wallHeight, rayIdx, rayResult.wallType, rayResult.actualDistance, 0, rayResult.side, rayResult.textureIndex, rayResult.mapX, rayResult.mapY);
strip.visible = true;
// Add to renderableObjects (cleared each frame in game.update, so always push)
renderableObjects.push({
distance: rayResult.actualDistance,
object: strip,
type: 'wall',
originalSortOrder: rayIdx
});
} else {
// Clear strip if no wall was hit
var strip = wallSegments[rayIdx];
if (strip && strip.clearStrip) {
strip.clearStrip();
strip.visible = false;
}
// Ensure wallHit is false for updateRaycastVisuals
rayResult.wallHit = false;
}
}
}
function updateRaycastVisuals() {
// floorCaster.update is handled by the main rayCasting function if this optimization path is taken.
// Or, if floor/ceiling should update even on minor moves, it can be called here too.
// For now, following prompt implies floor/ceiling only updates on fullRayCasting.
// Process all rays using cached data
for (var rayIdx = 0; rayIdx < NUM_RAYS; rayIdx++) {
var rayResult = rayResults[rayIdx]; // Use cached data
if (rayResult.wallHit) {
// Check cached wallHit
// Update strip with cached values
var strip = wallSegments[rayIdx];
strip.x = rayResult.screenX;
strip.updateStrip(STRIP_WIDTH, rayResult.wallHeight, rayIdx, rayResult.wallType, rayResult.actualDistance, 0, rayResult.side, rayResult.textureIndex, rayResult.mapX, rayResult.mapY);
strip.visible = true;
// Add to renderableObjects using cached distance (cleared each frame in game.update, so always push)
renderableObjects.push({
distance: rayResult.actualDistance,
object: strip,
type: 'wall',
originalSortOrder: rayIdx
});
} else {
// Clear strip if no wall was hit (based on cache)
var strip = wallSegments[rayIdx];
if (strip && strip.clearStrip) {
strip.clearStrip();
strip.visible = false;
}
}
}
}
function rayCasting() {
// Check if player has moved enough to require new raycasting
// A small threshold helps prevent floating point inaccuracies from triggering constant raycasts.
var playerMoved = Math.abs(player.x - lastPlayerX) > 0.001 || Math.abs(player.y - lastPlayerY) > 0.001 || Math.abs(player.dir - lastPlayerDir) > 0.001;
// Only perform full raycasting if needed
if (playerMoved || shouldRaycast) {
// Perform full raycasting (this also updates floor/ceiling via floorCaster.update)
fullRayCasting();
// Update cached player position and state
lastPlayerX = player.x;
lastPlayerY = player.y;
lastPlayerDir = player.dir;
shouldRaycast = false; // Reset flag until next explicit request or significant move
} else {
// Just update the visual rendering of walls with already calculated ray data
// Floor/ceiling are NOT updated in this path, as per optimization.
updateRaycastVisuals();
}
}
function renderEntities() {
var allDynamicEntities = monsters.concat(treasures).concat(powerUps.filter(function (p) {
return !p.collected;
})).concat(fountains);
if (gate) {
allDynamicEntities.push(gate);
}
// visibleEntities array and local sorting are no longer needed here.
// Entities will be added to the global renderableObjects array.
for (var i = 0; i < allDynamicEntities.length; i++) {
var entity = allDynamicEntities[i];
entity.visible = false; // Hide by default, will be set true if it should be rendered.
var dx = entity.mapX - player.x;
var dy = entity.mapY - player.y;
var dist = Math.sqrt(dx * dx + dy * dy);
entity.renderDist = dist; // Store distance for sorting
if (dist >= MAX_RENDER_DISTANCE) {
// Too far to render
continue;
}
var angle = Math.atan2(dy, dx) - player.dir;
// Normalize angle to be within -PI to PI range
while (angle < -Math.PI) {
angle += Math.PI * 2;
}
while (angle > Math.PI) {
angle -= Math.PI * 2;
}
if (Math.abs(angle) < HALF_FOV) {
// Check if entity is within Field of View
var rayHit = castRayToPoint(player.x, player.y, entity.mapX, entity.mapY);
// Check if there's a clear Line of Sight to the entity
// (dist - 0.5 is a small tolerance, e.g. for entity's own depth/size)
if (!rayHit.hit || rayHit.dist > dist - 0.5) {
entity.visible = true; // Mark as visible for rendering
var screenX = (0.5 + angle / FOV) * 2048; // Project to screen X
var height = WALL_HEIGHT_FACTOR / dist; // Calculate perceived height
var scale = height / 100; // Assuming base asset height is 100 for scaling
entity.x = screenX;
// Y position calculation
var screenY_horizon = 2732 / 2;
var player_camera_height_projection_factor = WALL_HEIGHT_FACTOR * 0.5; // Simulates camera height
var screenY_for_floor_at_dist = screenY_horizon + player_camera_height_projection_factor / dist;
var z_to_screen_pixels_factor = WALL_HEIGHT_FACTOR;
if (entity instanceof Treasure) {
// Treasures are on the floor. Their anchor is 0.5 (center).
// To place the bottom of the treasure on the 'screenY_for_floor_at_dist' line,
// its center (entity.y) must be 'screenY_for_floor_at_dist - actualRenderedHeight / 2'.
// 'scale' is (WALL_HEIGHT_FACTOR / dist) / 100, calculated a few lines above.
// 'texture.height' is the original asset height.
var currentSprite = entity.isOpen ? entity.treasureOpenSprite : entity.treasureSprite;
// Per LK guidelines, .width and .height on an asset provide its original dimensions.
// The error indicates currentSprite.texture is undefined, so we use currentSprite.height instead.
var currentSpriteOriginalHeight = currentSprite.height;
var actualRenderedHeight = currentSpriteOriginalHeight * scale; // 'scale' is the calculated scale for this frame
// Adjusted from actualRenderedHeight / 2 (which is * 0.5) to * 0.45
// This subtracts a slightly smaller amount, making entity.y larger (lower on screen).
entity.y = screenY_for_floor_at_dist - actualRenderedHeight * 0.2;
} else if (entity instanceof PowerUp) {
var powerUpWorldZ = entity.verticalOffset; // PowerUps have verticalOffset from chest
var screenY_offset_due_to_worldZ = powerUpWorldZ * z_to_screen_pixels_factor / dist;
// currentBobOffset for PowerUp is in screen pixels.
entity.y = screenY_for_floor_at_dist - screenY_offset_due_to_worldZ + entity.currentBobOffset - height / 2;
} else if (entity instanceof Fountain) {
// Fountains are on the floor, similar to treasures.
var fountainSprite = entity.sprite;
var fountainSpriteOriginalHeight = fountainSprite.height;
var actualRenderedHeightFountain = fountainSpriteOriginalHeight * scale;
// Using * 0.2 based on treasure logic to position bottom edge appropriately
entity.y = screenY_for_floor_at_dist - actualRenderedHeightFountain * 0.2;
} else if (entity instanceof EyeballMonster) {
var eyeballWorldZ = entity.baseWorldYOffset; // currentBobbingYOffset removed
var screenY_offset_due_to_worldZ = eyeballWorldZ * z_to_screen_pixels_factor / dist;
entity.y = screenY_for_floor_at_dist - screenY_offset_due_to_worldZ; // Eyeball anchor is 0.5, already centered.
} else if (entity instanceof Monster || entity instanceof Gate || entity instanceof ImpMonster) {
// Calculate Y position based on size multiplier (if any)
var entitySizeMultiplier = entity.sizeMultiplier || 1.0; // Default to 1.0 if no multiplier
var screenY_horizon = 2732 / 2; // Ensure screenY_horizon is defined in this specific scope if not already
// 'height' should be available from earlier in the function: var height = WALL_HEIGHT_FACTOR / dist;
if (entitySizeMultiplier < 1.0) {
// For smaller entities, lower them proportionally
// This means their visual base aligns more with larger entities, rather than their center.
var sizeReduction = (1 - entitySizeMultiplier) * height * 0.5; // How much smaller they are in terms of screen height
entity.y = screenY_horizon + sizeReduction; // Add to lower them (Y increases downwards)
} else {
// Normal positioning for regular-sized or larger entities (center on horizon)
entity.y = screenY_horizon;
}
} else {
// Fallback for any other entity type (should ideally be handled above)
entity.y = screenY_horizon;
}
// Scale adjustments
if (entity instanceof PowerUp) {
var finalScale = scale * entity.spawnScaleMultiplier; // Apply spawn scale animation
entity.scale.set(finalScale, finalScale);
} else if (entity.sizeMultiplier !== undefined) {
// For monsters with custom size multipliers
var customScale = scale * entity.sizeMultiplier;
entity.scale.set(customScale, customScale);
} else {
// Default scaling for other entities
entity.scale.set(scale, scale);
}
// Apply distance-based tinting to entities
var SHADE_START_FACTOR_ENTITIES = 0.3; // Match floor/ceiling behavior
var MIN_SHADE_ENTITIES = 0.2; // General minimum shade for distant entities
var distanceRatioEntities = dist / MAX_RENDER_DISTANCE;
var entityShadeFactor = Math.max(MIN_SHADE_ENTITIES, 1 - distanceRatioEntities / SHADE_START_FACTOR_ENTITIES);
// --- Apply projectile light to entities ---
var totalProjectileLightInfluenceEntity = 0;
for (var k = 0; k < projectiles.length; k++) {
var proj = projectiles[k];
if (proj.active) {
var dxProjEntity = proj.worldX - entity.mapX;
var dyProjEntity = proj.worldY - entity.mapY;
var distToProjectileEntitySq = dxProjEntity * dxProjEntity + dyProjEntity * dyProjEntity;
if (distToProjectileEntitySq < PROJECTILE_LIGHT_RADIUS * PROJECTILE_LIGHT_RADIUS) {
var distToProjectileEntity = Math.sqrt(distToProjectileEntitySq);
var distRatioProjEntity = distToProjectileEntity / PROJECTILE_LIGHT_RADIUS;
var falloffEntity = Math.pow(Math.max(0, 1 - distRatioProjEntity), PROJECTILE_LIGHT_FALLOFF_EXPONENT);
totalProjectileLightInfluenceEntity += PROJECTILE_LIGHT_MAX_CONTRIBUTION * falloffEntity;
}
}
}
entityShadeFactor = Math.min(1.0, entityShadeFactor + totalProjectileLightInfluenceEntity);
// --- End projectile light for entities ---
var finalEntityTint;
if (entity instanceof Monster || entity instanceof EyeballMonster || entity instanceof ImpMonster) {
// For Monster, EyeballMonster, and ImpMonster, 'entity' is the main container.
// Tinting the container affects its children sprites. Base tint is white (0xFFFFFF).
var r_ge = Math.floor(0xFF * entityShadeFactor);
var g_ge = Math.floor(0xFF * entityShadeFactor);
var b_ge = Math.floor(0xFF * entityShadeFactor);
finalEntityTint = r_ge << 16 | g_ge << 8 | b_ge;
entity.tint = finalEntityTint;
// Alpha is managed by internal sprite animations or visibility logic.
} else if (entity instanceof Treasure) {
var baseTreasureTint = 0xdddddd; // Treasure has a specific base tint
var r_t = baseTreasureTint >> 16 & 0xFF;
var g_t = baseTreasureTint >> 8 & 0xFF;
var b_t = baseTreasureTint & 0xFF;
r_t = Math.floor(r_t * entityShadeFactor);
g_t = Math.floor(g_t * entityShadeFactor);
b_t = Math.floor(b_t * entityShadeFactor);
finalEntityTint = r_t << 16 | g_t << 8 | b_t;
var targetSprite = entity.isOpen ? entity.treasureOpenSprite : entity.treasureSprite;
targetSprite.tint = finalEntityTint;
// Preserve targetSprite.alpha for open/close animation.
} else if (entity instanceof PowerUp) {
// PowerUp sprite base tint is white (0xFFFFFF).
var r_p = Math.floor(0xFF * entityShadeFactor);
var g_p = Math.floor(0xFF * entityShadeFactor);
var b_p = Math.floor(0xFF * entityShadeFactor);
finalEntityTint = r_p << 16 | g_p << 8 | b_p;
entity.powerUpSprite.tint = finalEntityTint;
// Preserve entity.powerUpSprite.alpha for collection animation.
} else if (entity instanceof Fountain) {
var baseFountainTint = entity.state === 'full' ? 0xFFFFFF : 0x707070; // White when full, dark grey when empty
var r_ft = baseFountainTint >> 16 & 0xFF;
var g_ft = baseFountainTint >> 8 & 0xFF;
var b_ft = baseFountainTint & 0xFF;
r_ft = Math.floor(r_ft * entityShadeFactor);
g_ft = Math.floor(g_ft * entityShadeFactor);
b_ft = Math.floor(b_ft * entityShadeFactor);
finalEntityTint = r_ft << 16 | g_ft << 8 | b_ft;
entity.sprite.tint = finalEntityTint;
} else if (entity instanceof Gate) {
var baseGateTint = 0x00FF00; // Gate is tinted green
var r_ga = baseGateTint >> 16 & 0xFF;
var g_ga = baseGateTint >> 8 & 0xFF;
var b_ga = baseGateTint & 0xFF;
r_ga = Math.floor(r_ga * entityShadeFactor);
g_ga = Math.floor(g_ga * entityShadeFactor);
b_ga = Math.floor(b_ga * entityShadeFactor);
finalEntityTint = r_ga << 16 | g_ga << 8 | b_ga;
entity.gateSprite.tint = finalEntityTint;
// Preserve entity.gateSprite.alpha for its pulse animation.
}
// Add to global list for sorting with walls
renderableObjects.push({
distance: entity.renderDist,
object: entity,
type: 'entity'
});
}
}
}
// Sorting and adding to dynamicEntitiesContainer is now done in game.update,
// after both walls and entities have been added to renderableObjects.
// dynamicEntitiesContainer.removeChildren(); // Moved
// visibleEntities.sort(...); // Removed
// for (var j = 0; j < visibleEntities.length; j++) { // Removed
// dynamicEntitiesContainer.addChild(visibleEntities[j]);
// }
}
function castRayToPoint(startX, startY, targetX, targetY) {
var rayDirX = targetX - startX;
var rayDirY = targetY - startY;
var distance = Math.sqrt(rayDirX * rayDirX + rayDirY * rayDirY);
rayDirX /= distance;
rayDirY /= distance;
var mapCheckX = Math.floor(startX);
var mapCheckY = Math.floor(startY);
var stepSizeX = Math.abs(1 / rayDirX);
var stepSizeY = Math.abs(1 / rayDirY);
var stepX = rayDirX >= 0 ? 1 : -1;
var stepY = rayDirY >= 0 ? 1 : -1;
var sideDistX, sideDistY;
if (rayDirX < 0) {
sideDistX = (startX - mapCheckX) * stepSizeX;
} else {
sideDistX = (mapCheckX + 1.0 - startX) * stepSizeX;
}
if (rayDirY < 0) {
sideDistY = (startY - mapCheckY) * stepSizeY;
} else {
sideDistY = (mapCheckY + 1.0 - startY) * stepSizeY;
}
var hit = false;
var side = 0;
var distToWall = 0;
while (!hit && distToWall < distance) {
if (sideDistX < sideDistY) {
sideDistX += stepSizeX;
mapCheckX += stepX;
side = 0;
distToWall = sideDistX - stepSizeX;
} else {
sideDistY += stepSizeY;
mapCheckY += stepY;
side = 1;
distToWall = sideDistY - stepSizeY;
}
if (mapCheckX < 0 || mapCheckX >= MAP_SIZE || mapCheckY < 0 || mapCheckY >= MAP_SIZE || !map[mapCheckY] || !map[mapCheckY][mapCheckX]) {
break;
} else if (map[mapCheckY][mapCheckX].type === 1) {
hit = true;
}
}
return {
hit: hit,
dist: distToWall
};
}
function updateControls() {
var joystick = controlButtons.joystick;
var currentJoystickX, currentJoystickY, isJoystickConsideredActive;
if (joystickOverrideActive) {
currentJoystickX = joystickOverrideX;
currentJoystickY = joystickOverrideY;
isJoystickConsideredActive = true; // Movement continues based on pre-attack state
} else if (joystick && joystick.active) {
currentJoystickX = joystick.normalizedX;
currentJoystickY = joystick.normalizedY;
isJoystickConsideredActive = true;
} else {
currentJoystickX = 0;
currentJoystickY = 0;
isJoystickConsideredActive = false;
}
if (isJoystickConsideredActive) {
controls.forward = currentJoystickY < -0.3;
controls.backward = currentJoystickY > 0.3;
controls.left = currentJoystickX < -0.3;
controls.right = currentJoystickX > 0.3;
} else {
controls.forward = false;
controls.backward = false;
controls.left = false;
controls.right = false;
}
// Check if player is in front of an interactable object (treasure or power-up)
var showInteract = false;
var interactableObject = null; // Generic variable for treasure or power-up
// Priority 1: Check for treasures
for (var i = 0; i < treasures.length; i++) {
var t = treasures[i];
if (t.isOpen) {
continue;
}
var dx_t = t.mapX - player.x;
var dy_t = t.mapY - player.y;
var dist_t = Math.sqrt(dx_t * dx_t + dy_t * dy_t);
if (dist_t < 0.7) {
//{cN} // Interaction range
var angleToTreasure = Math.atan2(dy_t, dx_t);
var angleDiff_t = Math.abs((player.dir - angleToTreasure + Math.PI * 3) % (Math.PI * 2) - Math.PI);
if (angleDiff_t < Math.PI / 3) {
// 60-degree cone
showInteract = true;
interactableObject = t;
break; //{cO}
}
}
}
// Priority 2: If no treasure, check for power-ups
if (!showInteract) {
for (var i = 0; i < powerUps.length; i++) {
var p = powerUps[i];
if (p.collected) {
continue;
}
var dx_p = p.mapX - player.x;
var dy_p = p.mapY - player.y;
var dist_p = Math.sqrt(dx_p * dx_p + dy_p * dy_p);
if (dist_p < 0.7) {
// Same interaction range
var angleToPowerUp = Math.atan2(dy_p, dx_p);
var angleDiff_p = Math.abs((player.dir - angleToPowerUp + Math.PI * 3) % (Math.PI * 2) - Math.PI);
if (angleDiff_p < Math.PI / 3) {
// Same 60-degree cone
showInteract = true;
interactableObject = p;
break;
}
}
}
}
// Priority 3: If no treasure or power-up, check for Fountains
if (!showInteract) {
for (var i = 0; i < fountains.length; i++) {
var f = fountains[i];
if (!f.isInteractable || f.state !== 'full') {
continue;
}
var dx_f = f.mapX - player.x;
var dy_f = f.mapY - player.y;
var dist_f = Math.sqrt(dx_f * dx_f + dy_f * dy_f);
if (dist_f < 0.7) {
// Interaction range
var angleToFountain = Math.atan2(dy_f, dx_f);
var angleDiff_f = Math.abs((player.dir - angleToFountain + Math.PI * 3) % (Math.PI * 2) - Math.PI);
if (angleDiff_f < Math.PI / 3) {
// 60-degree cone
showInteract = true;
interactableObject = f;
break;
}
}
}
}
// Update attack button's interact state and target
controlButtons.attack.isInteract = showInteract;
controlButtons.attack.interactTarget = interactableObject; // Use the new generic property name
// Read from attack/interact button
controls.attack = controlButtons.attack.pressed;
}
function getDistanceToNearestWall(x, y) {
var minDist = Infinity;
// Check a larger area around the player
var checkRadius = 2; // Check 2 cells in each direction
var startX = Math.max(0, Math.floor(x) - checkRadius);
var endX = Math.min(MAP_SIZE - 1, Math.floor(x) + checkRadius);
var startY = Math.max(0, Math.floor(y) - checkRadius);
var endY = Math.min(MAP_SIZE - 1, Math.floor(y) + checkRadius);
for (var cellY = startY; cellY <= endY; cellY++) {
for (var cellX = startX; cellX <= endX; cellX++) {
if (map[cellY] && map[cellY][cellX] && map[cellY][cellX].type === 1) {
// This is a wall cell, calculate distance to the nearest edge of this cell
var wallLeft = cellX;
var wallRight = cellX + 1;
var wallTop = cellY;
var wallBottom = cellY + 1;
// Calculate distance to the nearest edge of the wall cell
var distX = Math.max(0, Math.max(wallLeft - x, x - wallRight));
var distY = Math.max(0, Math.max(wallTop - y, y - wallBottom));
var distToWall = Math.sqrt(distX * distX + distY * distY);
minDist = Math.min(minDist, distToWall);
}
}
}
return minDist;
}
function pushPlayerAwayFromWalls() {
var currentDist = getDistanceToNearestWall(player.x, player.y);
if (currentDist < WALL_BUFFER) {
// Player is too close to a wall, find the direction away from the nearest wall
var pushDistance = WALL_BUFFER - currentDist + 0.05; // Add small extra margin
// Calculate the gradient (direction of steepest distance increase)
var gradStep = 0.01;
var distRight = getDistanceToNearestWall(player.x + gradStep, player.y);
var distLeft = getDistanceToNearestWall(player.x - gradStep, player.y);
var distUp = getDistanceToNearestWall(player.x, player.y - gradStep);
var distDown = getDistanceToNearestWall(player.x, player.y + gradStep);
var gradX = (distRight - distLeft) / (2 * gradStep);
var gradY = (distDown - distUp) / (2 * gradStep);
// Normalize the gradient
var gradMag = Math.sqrt(gradX * gradX + gradY * gradY);
if (gradMag > 0) {
gradX /= gradMag;
gradY /= gradMag;
// Move in the direction of the gradient
var newX = player.x + gradX * pushDistance;
var newY = player.y + gradY * pushDistance;
if (canMoveTo(newX, newY)) {
player.x = newX;
player.y = newY;
} else {
// If gradient direction doesn't work, try radial push
var angles = [0, Math.PI / 4, Math.PI / 2, 3 * Math.PI / 4, Math.PI, 5 * Math.PI / 4, 3 * Math.PI / 2, 7 * Math.PI / 4];
for (var i = 0; i < angles.length; i++) {
var testX = player.x + Math.cos(angles[i]) * pushDistance;
var testY = player.y + Math.sin(angles[i]) * pushDistance;
if (canMoveTo(testX, testY)) {
player.x = testX;
player.y = testY;
break;
}
}
}
}
}
}
function canMoveTo(targetX, targetY) {
var cellX = Math.floor(targetX);
var cellY = Math.floor(targetY);
// Check bounds: if target is outside map, cannot move.
if (cellX < 0 || cellX >= MAP_SIZE || cellY < 0 || cellY >= MAP_SIZE || !map[cellY] || !map[cellY][cellX]) {
return false;
}
// Check if the target cell itself is a wall
if (map[cellY][cellX].type === 1) {
return false;
}
// Prevent walking through treasures (if any treasure is in this cell and not open)
for (var i = 0; i < treasures.length; i++) {
var t = treasures[i];
if (Math.floor(t.mapX) === cellX && Math.floor(t.mapY) === cellY && !t.isOpen) {
return false;
}
}
// Check if the player would be too close to any wall
var distToWall = getDistanceToNearestWall(targetX, targetY);
return distToWall >= WALL_BUFFER;
}
function canMonsterMoveTo(targetX, targetY, monster) {
var targetCellX = Math.floor(targetX);
var targetCellY = Math.floor(targetY);
// Check bounds: if target is outside map, cannot move.
if (targetCellX < 0 || targetCellX >= MAP_SIZE || targetCellY < 0 || targetCellY >= MAP_SIZE || !map[targetCellY] || !map[targetCellY][targetCellX]) {
return false;
}
// Check if the target cell itself is a wall
if (map[targetCellY][targetCellX].type === 1) {
return false;
}
// Check if another monster is already in the target cell
for (var i = 0; i < monsters.length; i++) {
var otherMonster = monsters[i];
if (otherMonster !== monster && Math.floor(otherMonster.mapX) === targetCellX && Math.floor(otherMonster.mapY) === targetCellY) {
return false; // Target cell occupied by another monster
}
}
// Check if the monster would be too close to any wall using its own buffer
var distToActualWall = getDistanceToNearestWall(targetX, targetY);
return distToActualWall >= MONSTER_WALL_BUFFER;
}
function updateMonsterPosition(monster, newMapX, newMapY) {
var oldCellX = Math.floor(monster.mapX);
var oldCellY = Math.floor(monster.mapY);
var newCellX = Math.floor(newMapX);
var newCellY = Math.floor(newMapY);
// Only update map cell occupancy if the monster is actually changing cells
if (oldCellX !== newCellX || oldCellY !== newCellY) {
if (map[oldCellY] && map[oldCellY][oldCellX]) {
// Check if old cell is valid
map[oldCellY][oldCellX].removeMonster();
}
// else: old cell was invalid or monster wasn't registered there.
if (map[newCellY] && map[newCellY][newCellX]) {
// Check if new cell is valid
map[newCellY][newCellX].addMonster();
} else {
// Fallback: If new cell is invalid (this implies an issue if canMonsterMoveTo passed)
// Re-register in old cell if it was valid, and don't move.
if (map[oldCellY] && map[oldCellY][oldCellX]) {
map[oldCellY][oldCellX].addMonster();
}
return; // Do not tween to an invalid position.
}
}
// Tween the monster's visual/logical position
tween(monster, {
mapX: newMapX,
mapY: newMapY
}, {
duration: 300,
// 300ms smooth animation
easing: function easing(t) {
return 1 - Math.pow(1 - t, 4);
} // quartOut easing
});
}
function updatePlayerMovement(deltaTime) {
var moveSpeed = PLAYER_MOVE_SPEED * deltaTime;
var turnSpeed = PLAYER_TURN_SPEED * deltaTime;
var didMove = false;
// Track player's last position to detect state changes
if (player.lastX === undefined) {
player.lastX = player.x;
}
if (player.lastY === undefined) {
player.lastY = player.y;
}
// Get joystick values for analog control
var joystick = controlButtons.joystick;
var turnAmount = 0;
var moveAmount = 0;
var currentJoystickX, currentJoystickY, isJoystickConsideredActiveForMovement;
if (joystickOverrideActive) {
currentJoystickX = joystickOverrideX;
currentJoystickY = joystickOverrideY;
isJoystickConsideredActiveForMovement = true;
} else if (joystick && joystick.active) {
currentJoystickX = joystick.normalizedX;
currentJoystickY = joystick.normalizedY;
isJoystickConsideredActiveForMovement = true;
} else {
currentJoystickX = 0;
currentJoystickY = 0;
isJoystickConsideredActiveForMovement = false;
}
// Handle rotation - use x-axis for turning
if (isJoystickConsideredActiveForMovement) {
var joystickMagnitudeX = Math.abs(currentJoystickX);
var rampedTurnValue = 0; // Initialize to 0, will be 0 if in dead zone
var JOYSTICK_TURN_DEAD_ZONE = 0.15; // Threshold below which joystick input for turning is minimal/zero
var JOYSTICK_TURN_RAMP_EXPONENT = 2.5; // Higher value means more ramping towards the edges
if (joystickMagnitudeX > JOYSTICK_TURN_DEAD_ZONE) {
// Remap the input from [JOYSTICK_TURN_DEAD_ZONE, 1.0] to [0.0, 1.0]
var normalizedInputX = (joystickMagnitudeX - JOYSTICK_TURN_DEAD_ZONE) / (1.0 - JOYSTICK_TURN_DEAD_ZONE);
// Apply ramping (power curve)
rampedTurnValue = Math.pow(normalizedInputX, JOYSTICK_TURN_RAMP_EXPONENT);
}
var rampedTurnAmount = Math.sign(currentJoystickX) * rampedTurnValue;
turnAmount = rampedTurnAmount * turnSpeed * 1.4; // Retain existing overall speed multiplier
player.dir += turnAmount;
while (player.dir < 0) {
player.dir += Math.PI * 2;
}
while (player.dir >= Math.PI * 2) {
player.dir -= Math.PI * 2;
}
}
// Also support digital controls for rotation
else if (controls.left) {
player.dir -= turnSpeed;
while (player.dir < 0) {
player.dir += Math.PI * 2;
}
} else if (controls.right) {
player.dir += turnSpeed;
while (player.dir >= Math.PI * 2) {
player.dir -= Math.PI * 2;
}
}
// Calculate movement vector
var dx = 0,
dy = 0;
// Handle movement - use y-axis for forward/backward
var actualMoveSpeed = moveSpeed * player.moveSpeedMultiplier;
if (isJoystickConsideredActiveForMovement) {
moveAmount = -currentJoystickY * actualMoveSpeed; // Negative because up is negative y
if (Math.abs(moveAmount) > 0.01) {
dx += Math.cos(player.dir) * moveAmount;
dy += Math.sin(player.dir) * moveAmount;
didMove = true;
}
}
// Also support digital controls for movement
else if (controls.forward) {
dx += Math.cos(player.dir) * actualMoveSpeed;
dy += Math.sin(player.dir) * actualMoveSpeed;
didMove = true;
} else if (controls.backward) {
dx -= Math.cos(player.dir) * actualMoveSpeed;
dy -= Math.sin(player.dir) * actualMoveSpeed;
didMove = true;
}
var inputMoveIntent = didMove;
didMove = false;
// Enhanced collision detection with proper wall sliding
if (dx !== 0 || dy !== 0) {
var currentX = player.x;
var currentY = player.y;
// Try moving to the exact target position first
var targetX = currentX + dx;
var targetY = currentY + dy;
if (canMoveTo(targetX, targetY)) {
// No collision, move normally
player.x = targetX;
player.y = targetY;
didMove = true;
} else {
// Collision detected, try wall sliding
var moved = false;
// Try moving only along X axis
if (dx !== 0 && canMoveTo(currentX + dx, currentY)) {
player.x = currentX + dx;
moved = true;
}
// Try moving only along Y axis
if (dy !== 0 && canMoveTo(currentX, currentY + dy)) {
player.y = currentY + dy;
moved = true;
}
// If basic sliding failed, try gradual movement
if (!moved) {
var maxSteps = 10;
for (var step = 1; step <= maxSteps; step++) {
var fraction = step / maxSteps;
// Try X movement with varying intensity
if (dx !== 0) {
var testX = currentX + dx * fraction;
if (canMoveTo(testX, currentY)) {
player.x = testX;
moved = true;
break;
}
}
// Try Y movement with varying intensity
if (dy !== 0) {
var testY = currentY + dy * fraction;
if (canMoveTo(currentX, testY)) {
player.y = testY;
moved = true;
break;
}
}
}
}
didMove = moved;
}
}
// Always ensure player maintains proper distance from walls
pushPlayerAwayFromWalls();
// Play walk sound if there was input intent AND actual movement occurred
if (didMove && inputMoveIntent && LK.ticks % 20 === 0) {
LK.getSound('walk').play();
}
// Only attempt to attack if attack button is pressed and we can attack
if (controls.attack && canAttack) {
attackAction();
}
// Check for collisions with monsters
checkMonsterCollisions();
// Check for collisions with treasures
checkTreasureCollisions();
// Check for gate collision
checkGateCollision();
// Check for collisions with power-ups
checkPowerUpCollisions();
// Update player marker on minimap
updateMiniMap();
// Update player's last position
player.lastX = player.x;
player.lastY = player.y;
}
function checkPowerUpCollisions() {
for (var i = powerUps.length - 1; i >= 0; i--) {
var powerUp = powerUps[i];
if (powerUp.collected) {
continue;
}
var dx = powerUp.mapX - player.x;
var dy = powerUp.mapY - player.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 0.6) {
// Power-ups are no longer collected by collision.
// This block is intentionally left empty or can be removed if no other proximity logic is needed.
// For now, we ensure no collection happens here.
}
}
}
function attackAction() {
// Check if attack is on cooldown
if (!canAttack) {
return;
}
// Set attack on cooldown
canAttack = false;
var currentAttackCooldown = attackCooldown;
if (player.attackBuffActive) {
currentAttackCooldown /= 2; // Halve cooldown if attack buff is active
}
// Create and fire projectile
var projectile = new Projectile();
projectile.fire(globalRightHand.x, globalRightHand.y - 400);
// Add hand recoil animation
tween(globalRightHand, {
y: 2732 - 50,
rotation: -0.1
}, {
duration: 100,
onFinish: function onFinish() {
tween(globalRightHand, {
y: 2732,
rotation: 0
}, {
duration: 300
});
}
});
// Add to projectile layer
projectiles.push(projectile);
projectileLayer.addChild(projectile);
// Update attack button visual
var attackButton = controlButtons.attack;
attackButton.updateCooldown(0);
// Cooldown animation
var _cooldownTick = function cooldownTick(progress) {
attackButton.updateCooldown(progress);
if (progress < 1) {
LK.setTimeout(function () {
_cooldownTick(progress + 0.05);
}, currentAttackCooldown / 20); // Use currentAttackCooldown
}
};
_cooldownTick(0);
// Reset cooldown after specified time
LK.setTimeout(function () {
canAttack = true;
}, currentAttackCooldown); // Use currentAttackCooldown
}
function updateProjectiles(deltaTime) {
for (var i = projectiles.length - 1; i >= 0; i--) {
var projectile = projectiles[i];
// Update projectile position
var remove = projectile.update(deltaTime);
// Check if projectile has gone off screen or should be removed
if (remove) {
// Remove projectile
projectile.destroy();
projectiles.splice(i, 1);
continue;
}
// Check if projectile hits a monster
var hitMonster = false;
var hitMonsterIndex = -1;
for (var j = 0; j < monsters.length; j++) {
var monster = monsters[j];
if (monster.visible) {
// Get the vector from player to monster in world space
var monsterDx = monster.mapX - player.x;
var monsterDy = monster.mapY - player.y;
// Calculate distance to monster in world space
var monsterDist = Math.sqrt(monsterDx * monsterDx + monsterDy * monsterDy);
// Calculate the projectile's travel distance in world units
var projectileWorldDist = projectile.distance;
// Calculate depth difference: projectile must be at a similar depth to the monster.
var distanceDifference = Math.abs(projectileWorldDist - monsterDist);
var depthHit = distanceDifference < 0.5; // Projectile within 0.5 world units of monster's depth.
// Calculate horizontal collision based on monster's apparent width.
// monster.width is its current scaled width on screen (approximately WALL_HEIGHT_FACTOR / monsterDist).
var horizontalDifference = Math.abs(projectile.x - monster.x);
// The collision zone is a factor of the monster's full apparent width.
// A factor of 0.6 means the projectile's center needs to be within 0.6 * monster.width of the monster's screen center X.
// If monster.width is its visual width (e.g. 100px), this means horizontalDifference < 60px.
// The monster's visual half-width is monster.width / 2 (e.g. 50px).
// So, this allows the projectile's center to be (0.6 - 0.5) = 0.1 * monster.width (e.g. 10px) outside the visual edge.
var COLLISION_WIDTH_FACTOR = 0.6; // Adjust this for more/less generous horizontal collision.
var horizontalHit = horizontalDifference < monster.width * COLLISION_WIDTH_FACTOR;
// Hit if projectile is close enough in depth AND horizontally aligned considering monster's perceived width.
if (depthHit && horizontalHit) {
// NEW: Check if this projectile has already hit this specific monster
if (projectile.hitMonsters.indexOf(monster) !== -1) {
continue; // Already hit this monster, check the next one
}
// If not, this is a new monster for this projectile. Record the hit.
projectile.hitMonsters.push(monster);
hitMonster = true;
hitMonsterIndex = j;
break; //{i2} // Found a new monster to hit, break from monster loop
}
}
}
// Handle monster hit logic (unchanged)
if (hitMonster && hitMonsterIndex !== -1) {
var monster = monsters[hitMonsterIndex];
var killed = monster.takeDamage(player.attackPower); // Use player.attackPower
// Create a small red particle explosion at the monster's hit location
createParticleExplosion(monster.mapX, monster.mapY, 8, 0xFF0000, false); // Red Tint, 8 particles, isWallHit = false
if (killed) {
// Create larger particle explosion for monster death at monster's world position
createParticleExplosion(monster.mapX, monster.mapY, 150, 0xFF0000, false); // Red Tint, isWallHit = false
map[Math.floor(monster.mapY)][Math.floor(monster.mapX)].removeMonster();
monster.destroy();
monsters.splice(hitMonsterIndex, 1);
player.score += 10;
updateUI();
checkLevelCompletion();
}
projectile.destroy();
projectiles.splice(i, 1);
}
}
}
function checkMonsterCollisions() {
var currentTime = Date.now();
for (var i = 0; i < monsters.length; i++) {
var monster = monsters[i];
var dx = monster.mapX - player.x;
var dy = monster.mapY - player.y;
var dist = Math.sqrt(dx * dx + dy * dy);
// Determine collision distance based on monster type
var collisionDistance = 0.5; // Default
if (monster instanceof ImpMonster) {
collisionDistance = 0.35; // Imps need to be closer to actually hit the player
}
if (dist < collisionDistance && monster.canAttack) {
// Player hit by monster
var damageTaken = 1;
if (player.defenseBuffActive) {
damageTaken = 0.5; // Halve damage if defense buff is active
}
player.health -= damageTaken;
updateUI();
// Visual feedback
LK.effects.flashScreen(0xff0000, 300);
// Play sound
LK.getSound('hit').play();
// Push player back slightly
player.x -= dx * 0.3;
player.y -= dy * 0.3;
// Set monster attack on cooldown
monster.canAttack = false;
monster.lastAttackTime = currentTime;
// Reset monster attack after cooldown
LK.setTimeout(function () {
monster.canAttack = true;
}, monster.attackCooldown);
// Visual feedback for monster attack
tween(monster, {
alpha: 0.5
}, {
duration: 200,
onFinish: function onFinish() {
tween(monster, {
alpha: 1
}, {
duration: 200
});
}
});
// Check game over
if (player.health <= 0) {
LK.showGameOver();
}
break;
}
}
}
function checkTreasureCollisions() {
for (var i = treasures.length - 1; i >= 0; i--) {
var treasure = treasures[i];
var dx = treasure.mapX - player.x;
var dy = treasure.mapY - player.y;
var dist = Math.sqrt(dx * dx + dy * dy);
// No auto-collect, handled by interact button
}
}
function checkGateCollision() {
if (!gate) {
return;
}
var dx = gate.mapX - player.x;
var dy = gate.mapY - player.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 0.7) {
// Check if all monsters need to be defeated first
if (monsters.length > 0) {
// Display message that monsters need to be defeated
var warningText = new Text2('Defeat all monsters\nbefore exiting!', {
size: 60,
fill: 0xFF5555
});
warningText.anchor.set(0.5, 0.5);
warningText.x = 2048 / 2;
warningText.y = 2732 / 2;
gameLayer.addChild(warningText);
// Remove text after a few seconds
LK.setTimeout(function () {
warningText.destroy();
}, 2000);
return;
}
// Player reached the gate - complete level
// Play collect sound
LK.getSound('collect').play();
// Add points for completing level
player.score += 50 * player.level;
// Remove gate from map
map[Math.floor(gate.mapY)][Math.floor(gate.mapX)].removeGate();
gate.destroy();
gate = null;
// Level up
player.level++;
storage.level = player.level;
// Restore health, respecting new maxHealth
player.health = Math.min(player.health + 2, player.maxHealth);
// Update UI
updateUI();
// Show level complete with gate messaging
var levelCompleteText = new Text2('Level ' + (player.level - 1) + ' Complete!\nYou found the exit!', {
size: 80,
fill: 0x00FF55
});
levelCompleteText.anchor.set(0.5, 0.5);
levelCompleteText.x = 2048 / 2;
levelCompleteText.y = 2732 / 2;
gameLayer.addChild(levelCompleteText);
// Generate new level after a delay
LK.setTimeout(function () {
levelCompleteText.destroy();
generateMap();
}, 2000);
}
}
function checkLevelCompletion() {
// Level is only considered "complete" if all monsters are defeated
// This allows the gate to activate
if (monsters.length === 0) {
// If gate is not visible, make it pulse more dramatically to draw attention
if (gate) {
tween(gate, {
alpha: 0.2
}, {
duration: 300,
onFinish: function onFinish() {
tween(gate, {
alpha: 1
}, {
duration: 300
});
}
});
// Show hint text
var gateHintText = new Text2('Find the exit gate!', {
size: 60,
fill: 0x00FF55
});
gateHintText.anchor.set(0.5, 0.5);
gateHintText.x = 2048 / 2;
gateHintText.y = 200;
gameLayer.addChild(gateHintText);
// Remove hint after a few seconds
LK.setTimeout(function () {
gateHintText.destroy();
}, 3000);
}
} else {
// Show hint text about defeating monsters first
if (gate && gate.visible && LK.ticks % 300 === 0) {
var monsterHintText = new Text2('Defeat all monsters to activate the exit!', {
size: 60,
fill: 0xFF5555
});
monsterHintText.anchor.set(0.5, 0.5);
monsterHintText.x = 2048 / 2;
monsterHintText.y = 200;
gameLayer.addChild(monsterHintText);
// Remove hint after a few seconds
LK.setTimeout(function () {
monsterHintText.destroy();
}, 3000);
}
}
}
function updateMiniMap() {
// Update player marker position on minimap
playerMarker.x = miniMap.x + player.x * CELL_SIZE * MINI_MAP_SCALE;
playerMarker.y = miniMap.y + player.y * CELL_SIZE * MINI_MAP_SCALE;
// Draw player direction indicator
var dirX = Math.cos(player.dir) * 15;
var dirY = Math.sin(player.dir) * 15;
}
// Game update method
game.update = function () {
var currentTime = Date.now();
var deltaTime = currentTime - lastTime;
lastTime = currentTime;
// Update controls
updateControls();
// Update player
updatePlayerMovement(deltaTime);
// --- Rendering Phase ---
renderableObjects = []; // Clear the list for the current frame
rayCasting(); // Populates renderableObjects with wall strips
renderEntities(); // Populates renderableObjects with dynamic entities
// Sort all collected objects by distance (farthest first)
// This ensures correct Z-ordering.
renderableObjects.sort(function (a, b) {
if (a.distance !== b.distance) {
return b.distance - a.distance; // Farthest objects (larger distance) come first in the array
}
// Tie-breaking for objects at the exact same distance:
// Render walls on top of entities if distances are equal.
// This means entities should come earlier in the sorted list (rendered first).
if (a.type === 'entity' && b.type === 'wall') {
return -1; // Entity (a) comes before wall (b)
}
if (a.type === 'wall' && b.type === 'entity') {
return 1; // Wall (a) comes after entity (b)
}
// For two walls at the same distance, maintain their original screen order (left to right)
if (a.type === 'wall' && b.type === 'wall') {
return a.originalSortOrder - b.originalSortOrder;
}
// For two entities at same distance, or other unhandled ties, default sort behavior (often stable)
return 0;
});
// Clear the main container for 3D scene elements and add sorted objects
dynamicEntitiesContainer.removeChildren();
for (var i = 0; i < renderableObjects.length; i++) {
var ro = renderableObjects[i];
// Only add the object if it's marked as visible.
// Note: ro.object.visible might have been set to false by rayCasting (for empty strips)
// or renderEntities (for occluded/off-FOV entities).
if (ro.object.visible) {
dynamicEntitiesContainer.addChild(ro.object);
}
}
// --- End of Rendering Phase ---
// Update monsters AI and animations
// Monster visibility (ro.object.visible) is now correctly set by renderEntities before this logic.
if (LK.ticks % 15 === 0) {
// Monster movement less frequent
for (var i = 0; i < monsters.length; i++) {
MonsterAI.moveTowardsPlayer(monsters[i], player.x, player.y, map);
}
}
for (var i = 0; i < monsters.length; i++) {
var monster = monsters[i];
// Check if monster is visible (set by renderEntities) and has moved
if (monster.visible && (monster.mapX !== monster.lastMoveX || monster.mapY !== monster.lastMoveY)) {
monster.updateAnimation();
// Store current position for next comparison
monster.lastMoveX = monster.mapX;
monster.lastMoveY = monster.mapY;
}
}
// Update projectiles
updateProjectiles(deltaTime);
// Update fountain particles
for (var i = 0; i < fountains.length; i++) {
if (fountains[i].updateParticles) {
fountains[i].updateParticles();
}
}
// Update particle explosions
particlePoolManager.updateActiveParticles();
};
// Global function to display power-up effect text messages
var powerUpEffectTextDisplaying = null; // To manage one text at a time
function showPowerUpEffectText(message) {
// If a text is already displaying, remove it first
if (powerUpEffectTextDisplaying && powerUpEffectTextDisplaying.parent) {
tween.stop(powerUpEffectTextDisplaying); // Stop any ongoing tween
tween.stop(powerUpEffectTextDisplaying); // Stop any ongoing tween
powerUpEffectTextDisplaying.parent.removeChild(powerUpEffectTextDisplaying);
powerUpEffectTextDisplaying.destroy();
powerUpEffectTextDisplaying = null;
}
var effectText = new Text2(message, {
size: 80,
fill: 0xFFFFFF,
stroke: 0x000000,
strokeThickness: 7,
align: 'center',
wordWrap: true,
wordWrapWidth: 1800
});
effectText.anchor.set(0.5, 0.5);
effectText.x = 2048 / 2;
effectText.y = 2732 / 2; // Positioned higher on the screen
effectText.alpha = 1.0;
gameLayer.addChild(effectText); // Assuming gameLayer is accessible globally
powerUpEffectTextDisplaying = effectText;
LK.setTimeout(function () {
if (effectText && effectText.parent) {
// Check if still valid
tween(effectText, {
alpha: 0
}, {
duration: 500,
// Fade out over 0.5 seconds
easing: tween.linear,
onFinish: function onFinish() {
if (effectText.parent) {
effectText.parent.removeChild(effectText);
}
effectText.destroy();
if (powerUpEffectTextDisplaying === effectText) {
powerUpEffectTextDisplaying = null;
}
}
});
}
}, 2500); // Start fade-out after 2.5 seconds
}
// Game initialization
setupGame();
// Event handlers
game.down = function (x, y, obj) {
activeControlForGlobalHandlers = null; // Reset at the start of a new touch
var eventObjForAttack = Object.assign({}, obj); // Fresh event object for attack button
eventObjForAttack.stopPropagation = false;
var attackBtn = controlButtons.attack;
var attackBtnLocalX = x - attackBtn.x;
var attackBtnLocalY = y - attackBtn.y;
// Use the same generous hit radius as in the original code
var attackBtnHitRadius = 150 * 3.5;
var attackBtnDistSq = attackBtnLocalX * attackBtnLocalX + attackBtnLocalY * attackBtnLocalY;
if (attackBtnDistSq <= attackBtnHitRadius * attackBtnHitRadius) {
activeControlForGlobalHandlers = attackBtn;
attackBtn.down(attackBtnLocalX, attackBtnLocalY, eventObjForAttack);
// If the touch is within the attack button's area, it's considered handled by the attack button,
// regardless of whether it internally decided to stop propagation (e.g. attack on cooldown).
// This prevents the joystick from also processing this touch.
return;
}
// If we reach here, the touch was NOT on the attack button.
// Now check for joystick interaction using original quadrant check.
var joystick = controlButtons.joystick;
if (x < 800 && y > 2732 - 800) {
activeControlForGlobalHandlers = joystick;
var eventObjForJoystick = Object.assign({}, obj); // Fresh event object for joystick
eventObjForJoystick.stopPropagation = false;
// Pass local coordinates to joystick.down
joystick.down(x - joystick.x, y - joystick.y, eventObjForJoystick);
}
};
game.up = function (x, y, obj) {
if (activeControlForGlobalHandlers === controlButtons.attack) {
var attackBtnLocalX = x - controlButtons.attack.x;
var attackBtnLocalY = y - controlButtons.attack.y;
controlButtons.attack.up(attackBtnLocalX, attackBtnLocalY, obj);
} else if (activeControlForGlobalHandlers === controlButtons.joystick) {
// Only call joystick.up if the joystick itself believes it's active
if (controlButtons.joystick.active) {
var joystickLocalX = x - controlButtons.joystick.x;
var joystickLocalY = y - controlButtons.joystick.y;
controlButtons.joystick.up(joystickLocalX, joystickLocalY, obj);
}
}
activeControlForGlobalHandlers = null; // Reset after touch ends
};
game.move = function (x, y, obj) {
// Handle general screen move
if (activeControlForGlobalHandlers === controlButtons.joystick) {
// Only call joystick.move if the joystick itself believes it's active
if (controlButtons.joystick.active) {
var joystickLocalX = x - controlButtons.joystick.x;
var joystickLocalY = y - controlButtons.joystick.y;
controlButtons.joystick.move(joystickLocalX, joystickLocalY, obj);
}
}
// Attack button doesn't typically have a .move action, so no changes needed for it here.
};
var MonsterAI = {
moveTowardsPlayer: function moveTowardsPlayer(monster, playerX, playerY, map) {
var monsterX = monster.mapX;
var monsterY = monster.mapY;
var dxToPlayer = playerX - monsterX;
var dyToPlayer = playerY - monsterY;
var distToPlayer = Math.sqrt(dxToPlayer * dxToPlayer + dyToPlayer * dyToPlayer);
// Update monster's perception of player (line of sight)
var hasClearLOS = false;
if (distToPlayer <= MONSTER_AGGRO_RANGE) {
// Only check LOS if within aggro range
var rayHit = castRayToPoint(monsterX, monsterY, playerX, playerY);
// Player is visible if ray doesn't hit a wall, or hits a wall AFTER the player (minus small tolerance)
hasClearLOS = !rayHit.hit || rayHit.dist >= distToPlayer - 0.1;
}
monster.canSeePlayer = hasClearLOS; // Update this flag based on actual LOS
// Determine if monster should chase
var shouldChase = false;
if (distToPlayer <= MONSTER_AGGRO_RANGE) {
if (monster.canSeePlayer) {
// Chase if has LOS within aggro range
shouldChase = true;
} else if (distToPlayer < MONSTER_CLOSE_PROXIMITY_RANGE) {
// Or chase if very close (and within aggro), even without LOS
shouldChase = true;
}
}
if (!shouldChase) {
monster.pathToPlayer = []; // Clear path if not chasing
return;
}
// Monster is now committed to chasing.
// For pathfinding, this is where monster.pathToPlayer would be calculated.
// For now, direct movement uses a simplified path.
monster.pathToPlayer = [{
x: playerX,
y: playerY
}];
// DISTANCE MANAGEMENT AND ATTACK ANIMATION LOGIC
// If monster is close enough (MAX_MONSTER_PLAYER_ENGAGEMENT_DISTANCE), it might try to attack (animation) and then stop moving for this tick.
// MIN_MONSTER_PLAYER_DISTANCE is the same as MAX_MONSTER_PLAYER_ENGAGEMENT_DISTANCE (0.6), so one check suffices.
// Determine engagement distance based on monster type
var engagementDistance = MAX_MONSTER_PLAYER_ENGAGEMENT_DISTANCE; // Default 0.6
if (monster instanceof ImpMonster) {
engagementDistance = 0.4; // Imps need to get closer due to their smaller size
}
if (distToPlayer <= engagementDistance) {
if (monster.canAttack) {
// 'canAttack' is the cooldown flag managed by checkMonsterCollisions
if ((monster instanceof EyeballMonster || monster instanceof ImpMonster) && !monster.isAttacking) {
monster.performAttackAnimation();
// Actual damage and cooldown reset (monster.canAttack = false) happens in checkMonsterCollisions.
}
// For other monster types, attack animation could be triggered here.
}
return; // Stop movement this tick if in attack range (regardless of attack cooldown status for movement).
}
// MOVEMENT LOGIC (if not in attack range but shouldChase is true)
var effectiveMoveSpeed = typeof monster.moveSpeed !== 'undefined' ? monster.moveSpeed : 0.2;
var moveDirX = 0; // Renamed from dirX to avoid conflict if monster has 'dirX' property
var moveDirY = 0; // Renamed from dirY
if (distToPlayer > 0) {
// Avoid division by zero
moveDirX = dxToPlayer / distToPlayer;
moveDirY = dyToPlayer / distToPlayer;
}
var potentialNewX = monsterX + moveDirX * effectiveMoveSpeed;
var potentialNewY = monsterY + moveDirY * effectiveMoveSpeed;
// Try direct move first
if (canMonsterMoveTo(potentialNewX, potentialNewY, monster)) {
updateMonsterPosition(monster, potentialNewX, potentialNewY);
} else {
// Collision or too close to wall, try sliding
var slid = false;
// Try X-only movement
if (moveDirX !== 0 && canMonsterMoveTo(monsterX + moveDirX * effectiveMoveSpeed, monsterY, monster)) {
updateMonsterPosition(monster, monsterX + moveDirX * effectiveMoveSpeed, monsterY);
slid = true;
}
// Try Y-only movement (only if X-only didn't work or Y component is significant)
if (!slid && moveDirY !== 0 && canMonsterMoveTo(monsterX, monsterY + moveDirY * effectiveMoveSpeed, monster)) {
updateMonsterPosition(monster, monsterX, monsterY + moveDirY * effectiveMoveSpeed);
slid = true;
}
// If still no move (e.g. stuck in a corner), it will just stay put for this tick.
}
}
};
function ensureMapConnectivity() {
// Flood fill from player starting position to check accessibility
var visited = [];
for (var y = 0; y < MAP_SIZE; y++) {
visited[y] = [];
for (var x = 0; x < MAP_SIZE; x++) {
visited[y][x] = false;
}
}
var queue = [];
// Start from player position (1,1)
queue.push({
x: 1,
y: 1
});
visited[1][1] = true;
// Perform flood fill
while (queue.length > 0) {
var current = queue.shift();
var x = current.x;
var y = current.y;
// Check all four neighbors
var neighbors = [{
x: x + 1,
y: y
}, {
x: x - 1,
y: y
}, {
x: x,
y: y + 1
}, {
x: x,
y: y - 1
}];
for (var i = 0; i < neighbors.length; i++) {
var nx = neighbors[i].x;
var ny = neighbors[i].y;
// Check if neighbor is valid and not visited
if (nx >= 0 && nx < MAP_SIZE && ny >= 0 && ny < MAP_SIZE && map[ny][nx].type === 0 && !visited[ny][nx]) {
visited[ny][nx] = true;
queue.push({
x: nx,
y: ny
});
}
}
}
// Check for unreachable areas and create paths to them
for (var y = 1; y < MAP_SIZE - 1; y++) {
for (var x = 1; x < MAP_SIZE - 1; x++) {
// If it's a floor tile but wasn't visited, it's unreachable
if (map[y][x].type === 0 && !visited[y][x]) {
// Find the nearest accessible cell
var nearestX = -1;
var nearestY = -1;
var minDist = MAP_SIZE * MAP_SIZE;
for (var cy = 1; cy < MAP_SIZE - 1; cy++) {
for (var cx = 1; cx < MAP_SIZE - 1; cx++) {
if (visited[cy][cx]) {
var dist = (cx - x) * (cx - x) + (cy - y) * (cy - y);
if (dist < minDist) {
minDist = dist;
nearestX = cx;
nearestY = cy;
}
}
}
}
// Create a path from the unreachable area to the nearest accessible area
if (nearestX !== -1) {
createPath(x, y, nearestX, nearestY);
// Update visited map by doing a mini flood fill from this newly connected point
var pathQueue = [{
x: x,
y: y
}];
visited[y][x] = true;
while (pathQueue.length > 0) {
var current = pathQueue.shift();
var px = current.x;
var py = current.y;
var pathNeighbors = [{
x: px + 1,
y: py
}, {
x: px - 1,
y: py
}, {
x: px,
y: py + 1
}, {
x: px,
y: py - 1
}];
for (var j = 0; j < pathNeighbors.length; j++) {
var nx = pathNeighbors[j].x;
var ny = pathNeighbors[j].y;
if (nx >= 0 && nx < MAP_SIZE && ny >= 0 && ny < MAP_SIZE && map[ny][nx].type === 0 && !visited[ny][nx]) {
visited[ny][nx] = true;
pathQueue.push({
x: nx,
y: ny
});
}
}
}
}
}
}
}
}
function createPath(startX, startY, endX, endY) {
// Determine direction (horizontal or vertical first)
if (Math.random() < 0.5) {
// Horizontal first, then vertical
var x = startX;
while (x !== endX) {
x += x < endX ? 1 : -1;
if (map[startY][x].type === 1) {
map[startY][x].setType(0); // Convert wall to floor
}
}
var y = startY;
while (y !== endY) {
y += y < endY ? 1 : -1;
if (map[y][endX].type === 1) {
map[y][endX].setType(0); // Convert wall to floor
}
}
} else {
// Vertical first, then horizontal
var y = startY;
while (y !== endY) {
y += y < endY ? 1 : -1;
if (map[y][startX].type === 1) {
map[y][startX].setType(0); // Convert wall to floor
}
}
var x = startX;
while (x !== endX) {
x += x < endX ? 1 : -1;
if (map[endY][x].type === 1) {
map[endY][x].setType(0); // Convert wall to floor
}
}
}
}
Fullscreen modern App Store landscape banner, 16:9, high definition, for a game titled "RayCaster Dungeon Crawler" and with the description "A first-person dungeon crawler using ray casting technology to create a pseudo-3D experience. Navigate maze-like dungeons, defeat monsters, and collect treasures as you explore increasingly challenging levels with authentic retro visuals.". No text on banner!
Tan wall. In-Game asset. 2d. High contrast. No shadows
A blue glowing orb of magic. Pixel art. In-Game asset. 2d. High contrast. No shadows
A tile of grey and mossy dungeon stone floor. Pixel art.. In-Game asset. 2d. High contrast. No shadows
Arm up in the air.
A unlit metal cage sconce like you find on a dungeon wall. White candle inside. Pixel art.. In-Game asset. 2d. High contrast. No shadows
A white spider web. Pixelated retro.. In-Game asset. 2d. High contrast. No shadows
A round fireball projectile. Straight on view as if it’s coming straight towards the camera. Retro pixel art.. In-Game asset. 2d. High contrast. No shadows
A stone staircase icon. Side profile. Pixel art.. In-Game asset. 2d. High contrast. No shadows
Pixel art logo for a game called ‘Demon’s Depths’. Big demon head with the title of the game split on top and bottom. The words are made of flame. White background In-Game asset. 2d. High contrast. No shadows
Background image of gate leading into a dark dungeon. Walls are grey and mossy stones. Retro pixel art.. In-Game asset. 2d. High contrast. No shadows
SVG made of grey stone bricks that says ‘Enter’. Retro pixel art. In-Game asset. 2d. High contrast. No shadows
dungeon
Music
playerprojectile
Sound effect
wallhit
Sound effect
walk
Sound effect
impCry
Sound effect
playerhurt
Sound effect
enemyexplosion
Sound effect
pixeldrop
Sound effect
eyeball
Sound effect
treasureopen
Sound effect
fountainsplash
Sound effect
powerup
Sound effect
ogre
Sound effect
fireball
Sound effect
bosschant
Sound effect
demonlaugh
Sound effect