var WORLD_WIDTH = 3500; var WORLD_HEIGHT = 600; var MAX_LIVES = 3; var CARD_NAMES = [ '1-gift-of-water-action', '2-grow-greens-action', '3-fine-dining-compost-dish-action', '4-buzzing-with-wisdom-action', '5-baking-flatbread', '6-mixed-wastemonster-action', '7-ingredient-detective-action', '8-mindful-eating-action', '9-fermentastic-action', '10-tree-planter', '11-seed-scout', '12-natural-spritz', '13-dashboard-designer', '14-onion-bodyguard', '15-local-investor' ]; var PLATFORMS = [ // Ground sections (with gaps between them = bottom holes) [0, 560, 400, 40], [500, 560, 300, 40], [900, 560, 200, 40], [1200, 560, 400, 40], [1700, 560, 200, 40], [2000, 560, 300, 40], [2400, 560, 200, 40], [2700, 560, 400, 40], [3200, 560, 300, 40], // Floating platforms (with cards) [200, 440, 120, 20], [400, 380, 100, 20], [600, 320, 120, 20], [850, 400, 100, 20], [1050, 340, 120, 20], [1300, 400, 100, 20], [1500, 320, 120, 20], [1750, 380, 100, 20], [1950, 300, 120, 20], [2200, 360, 100, 20], [2450, 280, 120, 20], [2700, 340, 100, 20], [2950, 300, 120, 20], [3200, 260, 100, 20], // High platforms (with card) [300, 240, 100, 20], // High platforms (with hazards — no cards) [700, 200, 120, 20], [1100, 220, 100, 20], [1600, 180, 120, 20], [2100, 200, 100, 20], [2600, 160, 120, 20], [3100, 180, 100, 20], ]; var CARD_POSITIONS = [ [240, 410], [440, 350], [640, 290], [890, 370], [1090, 310], [1340, 370], [1550, 290], [1790, 350], [1990, 270], [2240, 330], [2490, 250], [2740, 310], [2990, 270], [3240, 230], [340, 210] ]; // Hazard zones — [x, y, w, h] — on platforms with NO cards var PLATFORM_HAZARDS = [ // High platforms without cards [700, 192, 80, 12], [1100, 212, 80, 12], [1600, 172, 80, 12], [2100, 192, 80, 12], [2600, 152, 80, 12], [3100, 172, 80, 12], // On ground sections [650, 552, 80, 12], [1400, 552, 80, 12], [2150, 552, 80, 12], [2900, 552, 80, 12], ]; // Hazard zones at the bottom of ground gaps (bottom holes) var HOLE_HAZARDS = [ [450, 588, 60, 24], [850, 588, 60, 24], [1150, 588, 60, 24], [1550, 588, 60, 24], [1950, 588, 60, 24], [2350, 588, 60, 24], [2650, 588, 60, 24], [3100, 588, 60, 24], ]; // Combined hazard list var HAZARDS = PLATFORM_HAZARDS.concat(HOLE_HAZARDS); // Jump pads on ground in the later part of the level var JUMP_PADS = [ [2250, 548, 60, 12], [2500, 548, 60, 12], [2750, 548, 60, 12], [3350, 548, 60, 12], ]; // Food positions — scattered on ground sections (away from hazards) var FOOD_POSITIONS = [ [150, 540], [550, 540], [1000, 540], [1350, 540], [1850, 540], [2550, 540], [3050, 540], ]; class GameScene extends Phaser.Scene { constructor() { super('GameScene'); } create() { this.score = 0; this.totalCards = CARD_POSITIONS.length; this.canDoubleJump = true; this.wasOnGround = true; this.lives = MAX_LIVES; this.invulnerable = false; this.gameStarted = false; this.isDead = false; this.walkFrame = 0; this.walkTimer = 0; this.startTime = 0; this.elapsed = 0; this.bestTime = localStorage.getItem('fsys_agent_best_time') ? parseFloat(localStorage.getItem('fsys_agent_best_time')) : Infinity; this.physics.world.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT); // Parallax background layers this.cameras.main.setBackgroundColor(0x0F0F23); // Sky/stars layer — procedural gradient with stars var skyGfx = this.make.graphics({x: 0, y: 0}); skyGfx.fillGradientStyle(0x0a0a2e, 0x0a0a2e, 0x1a1a4e, 0x1a1a4e, 1); skyGfx.fillRect(0, 0, 960, 600); skyGfx.fillStyle(0xffffff, 0.5); for (var si = 0; si < 20; si++) { skyGfx.fillCircle( 10 + Math.random() * 940, 10 + Math.random() * 580, 0.5 + Math.random() * 1.5 ); } skyGfx.generateTexture('bg_sky', 960, 600); skyGfx.destroy(); this.add.image(480, 300, 'bg_sky').setScrollFactor(0).setDepth(-12); // Main background — two copies for endless tiling, second mirrored var bgTex = this.textures.get('bg_platform'); var bgScale = WORLD_HEIGHT / bgTex.getSourceImage().height; var bgWidth = bgTex.getSourceImage().width * bgScale; this.add.image(0, 0, 'bg_platform') .setOrigin(0, 0).setDisplaySize(bgWidth, WORLD_HEIGHT).setDepth(-10); this.add.image(bgWidth, 0, 'bg_platform') .setOrigin(0, 0).setDisplaySize(bgWidth, WORLD_HEIGHT).setFlipX(true).setDepth(-10); // Platforms — ground=bush+earth, floating=cloud this.platformGroup = this.physics.add.staticGroup(); for (var i = 0; i < PLATFORMS.length; i++) { var p = PLATFORMS[i]; var cx = p[0] + p[2] / 2; var cy = p[1] + p[3] / 2; // Invisible physics body var plat = this.add.rectangle(cx, cy, p[2], p[3], 0xffffff, 0.01); plat.setDepth(0); this.physics.add.existing(plat, true); this.platformGroup.add(plat); if (p[1] >= 560) { // Ground sections — earth base + bush tile overlay var earth = this.add.rectangle(cx, cy, p[2], p[3], 0x6b4423, 0.8); earth.setDepth(1); var bush = this.add.tileSprite(cx, cy, p[2], p[3], 'bush_tile'); bush.setDepth(3); bush.setTint(0xccbb99); } else { // Floating platforms — cloud backdrop var cloudImg = this.add.image(cx, cy + 6, 'cloud_tile'); cloudImg.setDepth(2); cloudImg.setDisplaySize(Math.max(p[2] + 40, 80), 60); cloudImg.setAlpha(0.8 + Math.random() * 0.2); } } // Cards this.cardGroup = this.physics.add.staticGroup(); for (var j = 0; j < CARD_POSITIONS.length; j++) { var cp = CARD_POSITIONS[j]; var cardKey = 'card_' + CARD_NAMES[j % CARD_NAMES.length]; var texExists = this.textures.exists(cardKey); var card = this.cardGroup.create(cp[0], cp[1], texExists ? cardKey : undefined); card.refreshBody(); card.setDepth(5); if (!texExists) card.setTint(0xffd700); this.tweens.add({ targets: card, y: card.y - 4, duration: 600 + Math.random() * 400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } // Hazards — toxic sludge pools with bubbling animation this.hazardGroup = this.physics.add.staticGroup(); for (var h = 0; h < HAZARDS.length; h++) { this.makeHazard(HAZARDS[h]); } // Jump pads — bouncy ground pads in later level this.jumpPadGroup = this.physics.add.staticGroup(); for (var jpi = 0; jpi < JUMP_PADS.length; jpi++) { var jp = JUMP_PADS[jpi]; var jx = jp[0] + jp[2] / 2; var jy = jp[1] + jp[3] / 2; var pad = this.add.image(jx, jy + 2, 'jump_pad'); pad.setDepth(8); this.physics.add.existing(pad, true); this.jumpPadGroup.add(pad); this.tweens.add({ targets: pad, alpha: { from: 0.7, to: 1 }, duration: 500 + Math.random() * 200, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } // Food — generate apple texture and place items var foodGfx = this.make.graphics({x: 0, y: 0}); foodGfx.fillStyle(0xff3333); foodGfx.fillCircle(10, 12, 10); foodGfx.fillStyle(0x33cc33); foodGfx.fillRect(8, 2, 4, 6); foodGfx.generateTexture('food_apple', 20, 22); foodGfx.destroy(); this.foodGroup = this.physics.add.staticGroup(); for (var fi = 0; fi < FOOD_POSITIONS.length; fi++) { var fp = FOOD_POSITIONS[fi]; var food = this.foodGroup.create(fp[0], fp[1], 'food_apple'); food.setDepth(5); food.refreshBody(); this.tweens.add({ targets: food, y: food.y - 3, duration: 800 + Math.random() * 400, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); } // Player — kangaroo this.player = this.physics.add.sprite(100, 400, 'player_idle'); this.player.setDepth(100); this.player.setScale(0.35); this.player.setCollideWorldBounds(true); this.player.body.setSize(60, 100); this.player.body.setOffset(22, 25); // Camera this.cameras.main.setBounds(0, 0, WORLD_WIDTH, WORLD_HEIGHT); this.cameras.main.startFollow(this.player, true, 0.15, 0.15); this.cameras.main.setDeadzone(100, 50); // Collisions this.physics.add.collider(this.player, this.platformGroup); this.physics.add.overlap(this.player, this.cardGroup, this.collectCard, null, this); this.physics.add.overlap(this.player, this.hazardGroup, this.hitHazard, null, this); this.physics.add.collider(this.player, this.jumpPadGroup, this.bouncePlayer, null, this); this.physics.add.overlap(this.player, this.foodGroup, this.collectFood, null, this); // Input this.input.addPointer(2); this.cursors = this.input.keyboard.createCursorKeys(); this.wasd = this.input.keyboard.addKeys('W,A,S,D'); // Touch controls — native DOM events for reliable multi-touch this.joystickActive = false; this.joystickTouchId = -1; this.joystickDir = 0; this.touchJump = false; this.touchJumpJustPressed = false; this.touchJumpTouchId = -1; var jBase = this.add.circle(120, 490, 55, 0xffffff, 0.12).setScrollFactor(0).setDepth(200); this.jKnob = this.add.circle(120, 490, 22, 0xffffff, 0.35).setScrollFactor(0).setDepth(201); this.add.text(120, 555, '\u25C0\u25B6', { fontFamily: 'Arial', fontSize: '13px', color: '#ffffff' }).setOrigin(0.5).setScrollFactor(0).setDepth(201); this.add.text(720, 570, 'JUMP', { fontFamily: 'Arial Black', fontSize: '18px', color: '#ffffff' }).setOrigin(0.5).setScrollFactor(0).setDepth(201).setAlpha(0.3); var canvas = this.sys.game.canvas; var self = this; function toGamePos(t) { var r = canvas.getBoundingClientRect(); return { x: (t.clientX - r.left) * (960 / r.width), y: (t.clientY - r.top) * (600 / r.height) }; } function handleTouch(e) { e.preventDefault(); for (var i = 0; i < e.changedTouches.length; i++) { var t = e.changedTouches[i]; var pos = toGamePos(t); if (e.type === 'touchstart') { if (pos.x < 240 && self.joystickTouchId < 0) { self.joystickActive = true; self.joystickTouchId = t.identifier; self.updateJoystickPos(pos.x, pos.y); } else if (pos.x >= 480 && self.touchJumpTouchId < 0) { self.touchJump = true; self.touchJumpJustPressed = true; self.touchJumpTouchId = t.identifier; } } else if (e.type === 'touchmove' && t.identifier === self.joystickTouchId) { self.updateJoystickPos(pos.x, pos.y); } else if (e.type === 'touchend' || e.type === 'touchcancel') { if (t.identifier === self.joystickTouchId) { self.joystickActive = false; self.joystickTouchId = -1; self.joystickDir = 0; self.jKnob.setPosition(120, 490); } if (t.identifier === self.touchJumpTouchId) { self.touchJump = false; self.touchJumpTouchId = -1; } } } } canvas.addEventListener('touchstart', handleTouch, { passive: false }); canvas.addEventListener('touchmove', handleTouch, { passive: false }); canvas.addEventListener('touchend', handleTouch, { passive: false }); canvas.addEventListener('touchcancel', handleTouch, { passive: false }); // HUD this.scoreText = this.add.text(16, 16, 'Cards: 0 / ' + this.totalCards, { fontFamily: 'Arial Black', fontSize: '18px', color: '#52ffff', stroke: '#000000', strokeThickness: 4 }).setScrollFactor(0).setDepth(200); this.heartsTexts = []; this.drawHearts(); // Timer (top-right) this.timerText = this.add.text(784, 16, '00:00', { fontFamily: 'Arial Black', fontSize: '20px', color: '#ffd700', stroke: '#000000', strokeThickness: 4 }).setOrigin(1, 0).setScrollFactor(0).setDepth(200); if (this.bestTime < Infinity) { this.add.text(944, 40, 'FSYS Agent: ' + this.formatTime(this.bestTime), { fontFamily: 'Arial', fontSize: '12px', color: '#b9cbbc', stroke: '#000000', strokeThickness: 2 }).setOrigin(1, 0).setScrollFactor(0).setDepth(200); } // Tutorial var isTouch = this.sys.game.device.input.touch; var tutMsg = isTouch ? 'Use the stick to move\nTap right side to jump\nCollect all cards!' : 'Arrow keys to move\nSpace to jump\nCollect all cards!'; this.tutorialText = this.add.text(480, 300, tutMsg, { fontFamily: 'Arial', fontSize: '20px', color: '#ffffff', stroke: '#000000', strokeThickness: 4, align: 'center' }).setOrigin(0.5).setScrollFactor(0).setDepth(200); this.input.once('pointerdown', this.startGame, this); this.input.keyboard.once('keydown', this.startGame, this); } updateJoystickPos(x, y) { var dx = x - 120; var dy = y - 490; var dist = Math.sqrt(dx * dx + dy * dy); var maxDist = 55; if (dist > maxDist) { dx = dx / dist * maxDist; dy = dy / dist * maxDist; } this.jKnob.setPosition(120 + dx, 490 + dy); this.joystickDir = dx > 15 ? 1 : dx < -15 ? -1 : 0; } makeHazard(hz) { var cx = hz[0] + hz[2] / 2; var cy = hz[1] + hz[3] / 2; // Glowing pool base var pool = this.add.rectangle(cx, cy, hz[2], hz[3], 0x55cc33, 0.55); pool.setDepth(8); var glow = this.add.rectangle(cx, cy, hz[2] + 6, hz[3] + 6, 0xccff44, 0.15); glow.setDepth(7); // Pool pulse this.tweens.add({ targets: [pool, glow], alpha: { from: 0.4, to: 0.7 }, duration: 600 + Math.random() * 300, yoyo: true, repeat: -1, ease: 'Sine.easeInOut' }); // Bubbles for (var b = 0; b < 3; b++) { (function(scene, hzRef) { var bx = hzRef[0] + 12 + Math.random() * (hzRef[2] - 24); var bubble = scene.add.circle(bx, hzRef[1] + hzRef[3] - 3, 2 + Math.random() * 2, 0xaaff88, 0.8); bubble.setDepth(9); var dur = 700 + Math.random() * 500; scene.tweens.add({ targets: bubble, y: hzRef[1] - 6, alpha: 0, scale: 1.8, duration: dur, delay: Math.random() * dur, repeat: -1, onRepeat: function() { bubble.y = hzRef[1] + hzRef[3] - 3; bubble.alpha = 0.8; bubble.setScale(1); bubble.x = hzRef[0] + 12 + Math.random() * (hzRef[2] - 24); } }); })(this, hz); } // Physics body var hazardBody = this.hazardGroup.create(cx, cy); hazardBody.setDisplaySize(hz[2], hz[3] + 4); hazardBody.refreshBody(); hazardBody.setVisible(false); } startGame() { if (this.gameStarted) return; this.gameStarted = true; this.startTime = this.time.now; this.tutorialText.setVisible(false); if (this.sys.game.device.input.touch) { var el = document.documentElement; if (el.requestFullscreen) el.requestFullscreen(); else if (el.webkitRequestFullscreen) el.webkitRequestFullscreen(); } } drawHearts() { for (var hi = 0; hi < this.heartsTexts.length; hi++) { this.heartsTexts[hi].destroy(); } this.heartsTexts = []; var full = Math.floor(this.lives); var hasHalf = (this.lives - full) >= 0.49; for (var hi = 0; hi < MAX_LIVES; hi++) { var style = { fontFamily: 'Arial Black', fontSize: '20px', stroke: '#000000', strokeThickness: 3 }; var t; if (hi < full) { style.color = '#ff4444'; t = this.add.text(16 + hi * 26, 44, '\u2764', style); } else if (hi === full && hasHalf) { style.color = '#ff8888'; t = this.add.text(16 + hi * 26, 44, '\u2764', style); t.setAlpha(0.5); } else { style.color = '#884444'; t = this.add.text(16 + hi * 26, 44, '\u2661', style); t.setAlpha(0.35); } t.setScrollFactor(0).setDepth(200); this.heartsTexts.push(t); } } update(time, delta) { if (this.isDead) return; if (!this.gameStarted) return; this.elapsed = (this.time.now - this.startTime) / 1000; this.timerText.setText(this.formatTime(this.elapsed)); // Fall into bottom hole — lose life and respawn if (this.player.y > WORLD_HEIGHT + 50) { this.takeDamage(); return; } var speed = 200; var onGround = this.player.body.blocked.down; if (onGround && !this.wasOnGround) { this.canDoubleJump = true; } this.wasOnGround = onGround; if (this.cursors.left.isDown || this.wasd.A.isDown || this.joystickDir < 0) { this.player.setVelocityX(-speed); this.player.setFlipX(true); } else if (this.cursors.right.isDown || this.wasd.D.isDown || this.joystickDir > 0) { this.player.setVelocityX(speed); this.player.setFlipX(false); } else { this.player.setVelocityX(0); } var jumpDown = this.cursors.space.isDown || this.cursors.up.isDown || this.wasd.W.isDown || this.touchJump; if (jumpDown && onGround) { this.player.setVelocityY(-420); this.canDoubleJump = true; if (this.cache.audio.exists('sfx_jump')) this.sound.play('sfx_jump', { volume: 0.4 }); } else if (!onGround && this.canDoubleJump) { var freshPress = Phaser.Input.Keyboard.JustDown(this.cursors.space) || Phaser.Input.Keyboard.JustDown(this.cursors.up) || Phaser.Input.Keyboard.JustDown(this.wasd.W) || this.touchJumpJustPressed; this.touchJumpJustPressed = false; if (freshPress) { this.player.setVelocityY(-420); this.canDoubleJump = false; if (this.cache.audio.exists('sfx_jump')) this.sound.play('sfx_jump', { volume: 0.4 }); for (var dj = 0; dj < 8; dj++) { var dot = this.add.circle(this.player.x + Phaser.Math.Between(-24, 24), this.player.y + 30, 2 + Math.random() * 3, 0xffffff, 0.7); dot.setDepth(15); this.tweens.add({ targets: dot, y: dot.y + Phaser.Math.Between(30, 70), x: dot.x + Phaser.Math.Between(-40, 40), alpha: 0, scale: 0.1, duration: 250 + Math.random() * 200, onComplete: function() { dot.destroy(); } }); } } } this.walkTimer += delta; if (!onGround) { this.player.setTexture('player_jump'); } else if (Math.abs(this.player.body.velocity.x) > 10) { if (this.walkTimer > 180) { this.walkTimer = 0; this.walkFrame = 1 - this.walkFrame; } this.player.setTexture(this.walkFrame === 0 ? 'player_walk_a' : 'player_walk_b'); } else { this.player.setTexture('player_idle'); this.walkTimer = 0; } } takeDamage() { if (this.invulnerable || this.isDead) return; this.lives = Math.max(0, this.lives - 1); this.drawHearts(); if (this.cache.audio.exists('sfx_hurt')) this.sound.play('sfx_hurt', { volume: 0.5 }); if (this.lives <= 0) { this.die(); return; } this.invulnerable = true; this.cameras.main.shake(100, 0.01); this.respawn(); var self = this; this.tweens.add({ targets: this.player, alpha: 0.3, duration: 80, yoyo: true, repeat: 7, onComplete: function() { self.player.setAlpha(1); self.invulnerable = false; } }); } hitHazard(player, hazard) { this.takeDamage(); } bouncePlayer(player, pad) { if (player.body.velocity.y < 0) return; player.setVelocityY(-580); this.tweens.add({ targets: pad, scaleY: 0.4, scaleX: 1.2, duration: 80, yoyo: true, ease: 'Quad.easeOut' }); if (this.cache.audio.exists('sfx_jump')) this.sound.play('sfx_jump', { volume: 0.3 }); } respawn() { this.player.setVelocity(0, 0); this.player.setPosition(Phaser.Math.Clamp(this.player.x, 100, WORLD_WIDTH - 100), 350); } collectCard(player, card) { card.destroy(); this.score++; this.scoreText.setText('Cards: ' + this.score + ' / ' + this.totalCards); if (this.cache.audio.exists('sfx_coin')) this.sound.play('sfx_coin', { volume: 0.3 }); var pop = this.add.text(card.x, card.y - 10, '+1', { fontFamily: 'Arial Black', fontSize: '16px', color: '#ffd700', stroke: '#000000', strokeThickness: 3 }).setOrigin(0.5).setDepth(150); this.tweens.add({ targets: pop, y: pop.y - 40, alpha: 0, duration: 600, onComplete: function() { pop.destroy(); } }); if (this.score >= this.totalCards) this.win(); } collectFood(player, food) { if (this.lives >= MAX_LIVES) return; food.destroy(); this.lives = Math.min(MAX_LIVES, this.lives + 0.5); this.drawHearts(); if (this.cache.audio.exists('sfx_coin')) this.sound.play('sfx_coin', { volume: 0.3 }); var pop = this.add.text(food.x, food.y - 10, '+0.5', { fontFamily: 'Arial Black', fontSize: '14px', color: '#ff6666', stroke: '#000000', strokeThickness: 3 }).setOrigin(0.5).setDepth(150); this.tweens.add({ targets: pop, y: pop.y - 30, alpha: 0, duration: 600, onComplete: function() { pop.destroy(); } }); } die() { if (this.isDead) return; this.isDead = true; this.gameStarted = false; this.player.setTint(0xff0000); this.player.setVelocity(0, -200); if (this.cache.audio.exists('sfx_hurt')) this.sound.play('sfx_hurt', { volume: 0.5 }); var self = this; this.time.delayedCall(1500, function() { self.scene.start('GameOver', { won: false, score: self.score, total: self.totalCards, time: self.elapsed, best: self.bestTime }); }); } win() { this.gameStarted = false; var isNewBest = false; if (this.elapsed < this.bestTime) { this.bestTime = this.elapsed; localStorage.setItem('fsys_agent_best_time', this.bestTime.toString()); isNewBest = true; } this.tweens.add({ targets: this.player, scale: 0.6, y: this.player.y - 50, duration: 500, ease: 'Bounce.easeOut' }); var self = this; this.time.delayedCall(1500, function() { self.scene.start('GameOver', { won: true, score: self.score, total: self.totalCards, time: self.elapsed, best: self.bestTime, newBest: isNewBest }); }); } formatTime(seconds) { var m = Math.floor(seconds / 60); var s = Math.floor(seconds % 60); return (m < 10 ? '0' : '') + m + ':' + (s < 10 ? '0' : '') + s; } }