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