diff --git a/README.md b/README.md index ca385f9..bd0614c 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ LAN test URL (from another device on your network): ## Gameplay Rules -- TRex starts with **3 hearts**. +- TRex starts with **5 hearts** (or **10 hearts** in VIP mode). +- Easy and Medium end with a portal that advances to the next difficulty while preserving score. - Moving forward earns score (**+1 per forward step unit**). - Water gaps cause instant death if landed in. - Ant collision costs 1 heart; jumping on ants crushes them. diff --git a/index.php b/index.php index f851fe7..a87ff7f 100644 --- a/index.php +++ b/index.php @@ -30,9 +30,8 @@ diff --git a/js/game.js b/js/game.js index 1cdb954..25b935a 100644 --- a/js/game.js +++ b/js/game.js @@ -98,7 +98,7 @@ function playSfx(type) { } catch (_) {} } -const waterGaps = [ +const baseWaterGaps = [ { x: 700, width: 130 }, { x: 1300, width: 150 }, { x: 2100, width: 170 }, @@ -143,9 +143,19 @@ function getBiomeAt(x) { } function getMaxHearts(accessMode) { - if (accessMode === 'vipPlus') return 9; - if (accessMode === 'vip') return 6; - return 3; + if (accessMode === 'vip') return 10; + return 5; +} + +function getWaterGaps(difficulty) { + if (difficulty !== 'easy') return baseWaterGaps; + return baseWaterGaps.map((gap) => ({ ...gap, width: Math.round(gap.width * 0.74) })); +} + +function getNextDifficulty(difficulty) { + if (difficulty === 'easy') return 'medium'; + if (difficulty === 'medium') return 'hard'; + return null; } function createInitialState(config = gameConfig) { @@ -162,6 +172,17 @@ function createInitialState(config = gameConfig) { maxHearts, hitCooldown: 0, floatingTexts: [], + worldCleared: false, + portal: { + active: false, + entered: false, + x: worldWidth - 180, + width: 52, + height: 92, + y: groundY - 92, + pulse: 0 + }, + waterGaps: getWaterGaps(config.difficulty), ants: [ { x: 1000, y: groundY - 20, width: 22, height: 20, vx: -65, alive: true }, { x: 1750, y: groundY - 20, width: 22, height: 20, vx: -70, alive: true }, @@ -326,7 +347,7 @@ function drawBackground() { ctx.fillRect(0, groundY, canvas.width, canvas.height - groundY); // Water and lava pits - for (const gap of waterGaps) { + for (const gap of state.waterGaps) { const sx = gap.x - state.cameraX; if (sx + gap.width < 0 || sx > canvas.width) continue; ctx.fillStyle = '#2e8bcf'; @@ -354,7 +375,7 @@ function drawBackground() { ctx.font = 'bold 20px Arial'; ctx.fillText(`Biome: ${biome.name}`, 18, 70); ctx.fillText(`Difficulty: ${gameConfig.difficulty.toUpperCase()}`, 18, 96); - const modeLabel = gameConfig.accessMode === 'vipPlus' ? 'VIP+ MODE' : gameConfig.accessMode === 'vip' ? 'VIP MODE' : 'STANDARD'; + const modeLabel = gameConfig.accessMode === 'vip' ? 'VIP MODE' : 'STANDARD MODE'; ctx.fillText(modeLabel, 18, 122); } @@ -436,6 +457,93 @@ function drawMeteors() { } } +function drawPortal() { + if (!state.portal.active) return; + + const portal = state.portal; + const sx = portal.x - state.cameraX; + if (sx + portal.width < 0 || sx > canvas.width) return; + + const pulse = (Math.sin(portal.pulse * 6) + 1) / 2; + const glowWidth = portal.width + 20; + const glowHeight = portal.height + 16; + + ctx.save(); + ctx.globalAlpha = 0.2 + pulse * 0.2; + ctx.fillStyle = '#84c7ff'; + ctx.fillRect(sx - 10, portal.y - 8, glowWidth, glowHeight); + ctx.restore(); + + ctx.fillStyle = '#2a2a56'; + ctx.fillRect(sx, portal.y, portal.width, portal.height); + ctx.fillStyle = '#4eb4ff'; + ctx.fillRect(sx + 4, portal.y + 4, portal.width - 8, portal.height - 8); + + for (let i = 0; i < 5; i++) { + const stripeY = portal.y + 10 + i * 15; + const wobble = Math.sin(portal.pulse * 7 + i * 0.9) * 4; + ctx.fillStyle = i % 2 === 0 ? '#dbf4ff' : '#8bd9ff'; + ctx.fillRect(sx + 10 + wobble, stripeY, portal.width - 20, 6); + } + + ctx.fillStyle = '#88e1ff'; + ctx.fillRect(sx - 4, portal.y + 2, 4, portal.height - 4); + ctx.fillRect(sx + portal.width, portal.y + 2, 4, portal.height - 4); + + ctx.fillStyle = '#0f0f0f'; + ctx.font = 'bold 14px Arial'; + ctx.fillText('PORTAL', sx - 8, portal.y - 10); +} + +function resetWorldForDifficulty(nextDifficulty) { + const score = state.score; + const hearts = state.hearts; + const maxHearts = state.maxHearts; + + gameConfig.difficulty = nextDifficulty; + const nextState = createInitialState(gameConfig); + state = nextState; + + state.score = score; + state.hearts = Math.min(hearts, maxHearts); + state.maxHearts = maxHearts; + state.running = true; + state.dead = false; +} + +function updatePortal(dt) { + state.portal.pulse += dt; + + const nextDifficulty = getNextDifficulty(gameConfig.difficulty); + if (!nextDifficulty) { + state.portal.active = false; + return; + } + + const p = state.player; + const atWorldEnd = p.x >= worldWidth - p.width - 8; + + if (atWorldEnd) { + state.portal.active = true; + state.worldCleared = true; + } + + if (!state.portal.active || state.portal.entered) return; + + const portalHitbox = { + x: state.portal.x, + y: state.portal.y, + width: state.portal.width, + height: state.portal.height + }; + + if (intersects(p, portalHitbox)) { + state.portal.entered = true; + addFloatingText(state.portal.x + state.portal.width / 2, state.portal.y - 14, `${nextDifficulty.toUpperCase()}!`, '#9fe6ff'); + resetWorldForDifficulty(nextDifficulty); + } +} + function addFloatingText(x, y, text, color = '#ffe066') { state.floatingTexts.push({ x, y, text, color, ttl: 0.8, vy: -36 }); } @@ -506,7 +614,7 @@ function applyHit(reason) { function isOverWater(player) { const centerX = player.x + player.width / 2; - return waterGaps.some((g) => centerX >= g.x && centerX <= g.x + g.width); + return state.waterGaps.some((g) => centerX >= g.x && centerX <= g.x + g.width); } function isOverLava(player) { @@ -701,6 +809,7 @@ function tick(ts) { if (state.running) { state.hitCooldown = Math.max(0, state.hitCooldown - dt); updatePlayer(dt); + updatePortal(dt); updateAnts(dt); updateCactus(); updateMonkeys(dt); @@ -719,6 +828,7 @@ function tick(ts) { drawMammoths(); drawPterodactyls(); drawMeteors(); + drawPortal(); drawFloatingTexts(); drawTRex(state.player);