User prompt
wait for the player to place a beat first, and only start counting from the next sequence after the first beat has been placed. so if a beat has been placed in this sequence and no coin has been colelcted because the beat was placed before the sequence point, you don't deduct a live yet, as you don't start counting, you only start counting after the first beat has been placed and the sequence has ended
User prompt
instead of starting the live removal from the very firstsequence, wait for the player to place a beat first, and only start counting from the next sequence after the first beat has been placed
User prompt
before triggering the game over screen, wait for the last Live to finish it's animation. move the trigger at the end of the animation
User prompt
once the hero touched the princess, we wait one more turn before actually starting the next round, but it feels like two turns because the hero can actually make another move to another cell. while it should wait for the hero to enter the princess cell, it should trigger the next roudn as soon as it landed not be allowed to move another cell
User prompt
only spawn the princess afetr collecting oiver 51% of the coins AND the key, both conditions must be true. also, please wait for the last final Live to finish it's animation and sisappear before triggering the game over state
User prompt
Step 3.A: Add a State Flag We need a global flag to ensure the princess only spawns once per round. At the top of your script with the other global variables, add: var princessHasSpawned = false; Use code with caution. JavaScript Step 3.B: Modify startNextRound The princess should not be placed at the beginning of the round anymore. In startNextRound: Find and delete the entire block of code that places the princess. It starts with var princessCell; and ends with gameGrid.grid[princessCell.row][princessCell.col] = princess;. Reset the Flag: At the end of startNextRound, before AudioService.playNewLevel(), add this line to reset the state for the new level: princessHasSpawned = false; Use code with caution. JavaScript Step 3.C: Implement the Spawning Logic We'll trigger this right after a coin is collected. In sequencerService.doStepActions: Inside the coin collection block (else if (entity.collect)), add the check for the 51% threshold. // Inside doStepActions, inside the Coin logic block, after entity.collect() collectedCoins++; // We need to manually track collected coins again // --- NEW PRINCESS SPAWNING LOGIC --- // Check if we've collected enough coins AND if the princess hasn't spawned yet. if (!princessHasSpawned && (collectedCoins / totalCoins) > 0.51) { princessHasSpawned = true; // Set flag immediately to prevent multiple spawns // Find a random empty cell to spawn the princess var emptyCells = []; for (var r = 0; r < gameGrid.playableRows; r++) { for (var c = 0; c < GameConstants.GRID_COLS; c++) { // An empty cell has no object in it if (gameGrid.grid[r][c] === null) { emptyCells.push({ row: r, col: c }); } } } if (emptyCells.length > 0) { var spawnCell = emptyCells[Math.floor(Math.random() * emptyCells.length)]; var princess = new Princess(); princess.x = GameConstants.GRID_LEFT + spawnCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; princess.y = GameConstants.GRID_TOP + spawnCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; game.addChild(princess); gameGrid.grid[spawnCell.row][spawnCell.col] = princess; // Optional: Play a special sound effect here AudioService.playNewLevel(); } } // --- END NEW PRINCESS SPAWNING LOGIC --- Use code with caution. JavaScript Fix collectedCoins and totalCoins: Your startNextRound logic is a bit off. Make sure totalCoins is set to the number of coins you actually place, and collectedCoins is reset to 0. Add collectedCoins = 0; in "Step B" of startNextRound.
User prompt
remove the projectile component and all logic that has to do with it, we no longer fire projectiles every sequence end
User prompt
show the lives UI as 3 distinct icons made of the Live asset and show them on a grid. remove them from right to left as they go away
User prompt
Step 2.C: Implement the Life-Loss Logic This is the heart of the new system. We'll modify sequencerService.doStepActions. In sequencerService.doStepActions, find where you check for coin collection (else if (entity.collect)). Inside that block, set your new flag to true. // Inside doStepActions, inside the Coin logic block entity.collect(); // ... this.collectedInSequence = true; // <-- ADD THIS LINE Use code with caution. JavaScript Now, we need to check the flag at the end of every 8-beat sequence and deduct a life if needed. The previousStep variable is perfect for this. The end of a sequence is when previousStep is 7 (the last column). Add this new logic block to sequencerService.doStepActions, right after you get the actions but before you perform them. // Inside doStepActions, after var actions = ... var previousStep = (this.currentStep - 1 + GameConstants.GRID_COLS) % GameConstants.GRID_COLS; // --- NEW LIFE LOGIC --- // Check if a full sequence has just ended (i.e., we're about to play beat 0) if (previousStep === GameConstants.GRID_COLS - 1) { if (!this.collectedInSequence) { // Player failed to collect a coin in the last 8 beats. Deduct a life. this.lives--; uiService.updateLivesUI(this.lives); // Update the visual display if (this.lives <= 0) { LK.showGameOver(); return; // Stop the game } } // Reset the flag for the new sequence. this.collectedInSequence = false; } // --- END NEW LIFE LOGIC --- var actions = this.gridService.getActiveActionsForStep(previousStep); // ... rest of the function ...
User prompt
The Core Mechanic - The "Life" System This new system penalizes inaction and keeps the pressure on. Step 2.A: Add State to the SequencerService The sequencer is the conductor of the round, so it's the perfect place to manage lives and track if a player is keeping the rhythm. In the SequencerService function, add these new properties: function SequencerService(...) { // ... existing properties ... this.lives = 3; this.collectedInSequence = false; // Tracks if a coin was collected in the current 8-beat loop } Use code with caution. JavaScript Step 2.B: Create the UI for Lives Players need to see their lives. Let's add this to the UIService. In the UIService function, add a new property for the lives text and initialize it. function UIService(game) { // ... this.livesTxt = null; this.initialize = function() { // ... your existing scoreTxt logic ... // Lives Text this.livesTxt = new Text2("Lives: 3", { size: 70, fill: "#FF6347" }); // Tomato color this.livesTxt.anchor.set(0, 0); // left-top LK.gui.topLeft.addChild(this.livesTxt); }; // ... } Use code with caution. JavaScript Add a new method to UIService to update the text: this.updateLivesUI = function(currentLives) { this.livesTxt.setText("Lives: " + currentLives); };
User prompt
now pls create a new asset named Live
User prompt
Delete the Enemy Class: Find the var Enemy = Container.expand(...) definition and delete the entire class. Remove Enemy Placement in startNextRound: Go to your startNextRound function. Find and delete the entire block of code responsible for placing enemies. It starts with var numEnemies = ... and ends with the for loop that places them. Remove Enemy Collision Logic in SequencerService: Go to sequencerService.doStepActions. Find the else if (!entity.collect) block, which was the logic for detecting an enemy. Delete this entire else if block. Your if/else if chain for entities will now just handle Key, Princess, and Coin.
User prompt
ensure coins can't spawn in the same cell as th hero
User prompt
now no coin are spawned at all
User prompt
The initBackground function should only be responsible for the background. The startNextRound function is already perfectly handling the placement of all game objects via the cellPool. Modify GameGrid.initBackground to only create the visual background cells. Remove the coin creation logic entirely. Change this: // Inside GameGrid.initBackground this.initBackground = function () { // ... (this part is good) for (var r = 0; r < this.rows; r++) { for (var c = 0; c < this.cols; c++) { var bgCell = LK.getAsset('emptycell', { /*...*/ }); bgCell.x = /*...*/; bgCell.y = /*...*/; this.game.addChild(bgCell); // REMOVE THE FOLLOWING "if" BLOCK if (r < this.playableRows) { var coin = new Coin(); coin.x = bgCell.x; coin.y = bgCell.y; this.game.addChild(coin); this.grid[r][c] = coin; } } } // No return value needed };
User prompt
The initBackground function should only be responsible for the background. The startNextRound function is already perfectly handling the placement of all game objects via the cellPool. Modify GameGrid.initBackground to only create the visual background cells. Remove the coin creation logic entirely. Change this: // Inside GameGrid.initBackground this.initBackground = function () { // ... (this part is good) for (var r = 0; r < this.rows; r++) { for (var c = 0; c < this.cols; c++) { var bgCell = LK.getAsset('emptycell', { /*...*/ }); bgCell.x = /*...*/; bgCell.y = /*...*/; this.game.addChild(bgCell); // REMOVE THE FOLLOWING "if" BLOCK if (r < this.playableRows) { var coin = new Coin(); coin.x = bgCell.x; coin.y = bgCell.y; this.game.addChild(coin); this.grid[r][c] = coin; } } } // No return value needed }; Use code with caution. JavaScript Call this function in startNextRound: You are already doing this, but now it will work correctly. It will draw the background without interfering with your cellPool logic that places the game pieces.
User prompt
We need to tell the game to stop processing win conditions the moment one is triggered. The cleanest place to manage this state is in the SequencerService. In SequencerService, add a state flag: function SequencerService(...) { // ... existing properties ... this.levelTransitioning = false; // Add this new flag // ... } Use code with caution. JavaScript In sequencerService.doStepActions, use the flag to prevent the double-trigger: // Inside doStepActions, in the Princess logic... else if (entity instanceof Princess) { if (playerHasKey && !this.levelTransitioning) { // <-- ADD THIS CHECK this.levelTransitioning = true; // <-- SET THE FLAG IMMEDIATELY // Player wins the round! LK.setTimeout(startNextRound, 600); return; } } Use code with caution. JavaScript In startNextRound, reset the flag so the next level can be won. This should happen at the very end of the function, after the new level is fully built and ready to play. function startNextRound() { // ... all of your existing code to build the new level ... // --- At the very end of the function --- sequencerService.levelTransitioning = false; // <-- RESET THE FLAG AudioService.playNewLevel(); } Use code with caution. JavaScript This ensures the win condition can only be triggered once per level.
User prompt
there's now a duplicate set of coins created which i cant collect, remove those from the code
User prompt
Refactor the startNextRound Function (The Main Fix) Now, let's rebuild startNextRound using these new, cleaner methods. This new order is critical. function startNextRound() { // --- Step A: Full System Reset --- // 1. Destroy ALL objects on the grid (playable AND beat cells). gameGrid.destroyAll(); // 2. Destroy the old hero. playerService.reset(); // --- Step B: Reset Game State --- currentRound++; playerHasKey = false; // --- Step C & D: Create and Shuffle the Cell Pool --- // (Your existing cellPool creation and shuffle logic is good and can stay here) var cellPool = []; // ... fill and shuffle the pool ... // --- Step E: Rebuild the World --- // 1. Re-initialize the background graphics. This must happen first. gameGrid.initBackground(); // 2. Re-initialize the NEW beat grid. gameGrid.initBeatGrid(); // 3. Initialize the NEW hero and get a reference to it. playerService.initialize(); var hero = playerService.getHeroInstance(); // 4. Place the Hero. (We need to update playerService.initialize to not place it randomly) // Let's modify playerService.initialize to NOT set the hero's position, // and place it here from the cell pool instead. var heroCell = cellPool.shift(); hero.gridRow = heroCell.row; hero.currentCol = heroCell.col; hero.x = GameConstants.GRID_LEFT + heroCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; hero.y = GameConstants.GRID_TOP + heroCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; game.addChild(hero); // Add the new hero to the game // --- Step F: Place Special Items and Coins --- // (Your existing logic for placing Princess, Key, Enemies, and Coins is good) // ... // --- Step G: Final Housekeeping --- // 1. Reset the Anti-Cheat Service for the new grid layout. antiCheatService.reset(); // 2. Reset all beat grid cells to inactive so no beats remain from the previous level. for (var r = 0; r < GameConstants.BEAT_ROWS; r++) { for (var c = 0; c < GameConstants.GRID_COLS; c++) { var cell = gameGrid.getBeatCell(r, c); if (cell && cell.active) { cell.setActive(false); gridService.updateCellVisual(cell); } } } // 3. Play the sound. AudioService.playNewLevel(); } Use code with caution. JavaScript Crucial Change to playerService.initialize: To make the above work, you need to slightly change playerService.initialize so it doesn't randomly place the hero. It should only create it. // In PlayerService this.initialize = function() { if (this.hero) this.hero.destroy(); this.hero = new Hero(); // DO NOT set position here. DO NOT add to game here. // That will be handled by startNextRound. };
User prompt
Make the PlayerService Responsible for Its Hero The PlayerService creates the hero, so it should be able to destroy it, too. In the PlayerService function, add a reset method: // Inside function PlayerService(...) this.reset = function() { if (this.hero) { this.hero.destroy(); this.hero = null; } }; Use code with caution. JavaScript And modify initialize to check if a hero already exists, just in case. this.initialize = function() { if (this.hero) { this.hero.destroy(); // Destroy old one if it exists } this.hero = new Hero(); // ... rest of initialize ... };
User prompt
Make the GameGrid Responsible for Its Own Cleanup The GameGrid creates the beat cells, so it should also destroy them. In the GameGrid function, add a new destroyAll method: // Inside function GameGrid(game) this.destroyAll = function() { // Destroy ALL objects currently managed by the grid (playable and beat) for (var r = 0; r < this.rows; r++) { for (var c = 0; c < this.cols; c++) { if (this.grid[r][c] && typeof this.grid[r][c].destroy === 'function') { this.grid[r][c].destroy(); } this.grid[r][c] = null; // Clear the reference } } };
User prompt
Redundant Code: There are several commented-out sections and multiple places where initialization logic used to be. A cleanup pass would improve readability.
User prompt
Modifying GameConstants at Runtime: In playerService.moveVertical, you have GameConstants.CHAR_ROW = newRow;. A "Constant" should never be changed at runtime. The hero's current row is a piece of state that belongs on the hero object (hero.gridRow), not in the global constants.
User prompt
Global gameGrid and antiCheatService: Services like PlayerService and SequencerService access gameGrid and antiCheatService as if they are global variables, which they are. This breaks encapsulation. These dependencies should be passed into their constructors, just like you're doing for game and uiService.
User prompt
Beat Action Timing is Off by One Beat: In sequencerService.doStepActions, you calculate actions based on the previousStep. This is a deliberate choice for audio sync, which is fine, but the hero's movement is also based on that previous step. This can feel laggy to the player. The hero's actions should almost always correspond to the current step (the one the highlight is on). You should get actions for this.currentStep and perform them, while playing audio for the previousStep.
/**** * Plugins ****/ var tween = LK.import("@upit/tween.v1"); /**** * Classes ****/ /** * BeatCell * Responsibility: To model the state of a single cell on the beat grid. It no longer * handles its own graphics updates directly, this is delegated to the GridService. */ var BeatCell = Container.expand(function (row) { var self = Container.call(this); self.active = false; self.row = row; // Know its row for external logic to interpret // Initialize with appropriate beat tile at 20% transparency var assetId = 'cell'; if (row === 0) { assetId = 'cellActive'; } else if (row === 1) { assetId = 'cellLeft'; } else if (row === 2) { assetId = 'cellRight'; } else if (row === 3) { assetId = 'cellDown'; } self.gfx = self.attachAsset(assetId, { anchorX: 0.5, anchorY: 0.5, alpha: 0.1 }); self.toggle = function () { self.active = !self.active; }; self.setActive = function (val) { if (self.active !== val) { self.toggle(); } }; // The `down` event is removed and is now handled by the central InputService. return self; }); /** * Coin * Responsibility: Collectible coin that can be picked up by the hero */ var Coin = Container.expand(function () { var self = Container.call(this); self.gfx = self.attachAsset('coin', { anchorX: 0.5, anchorY: 0.5 }); self.collected = false; // Add wobble animation with random offset self.wobbleOffset = Math.random() * Math.PI * 2; // Random starting phase self.wobbleSpeed = 0.0015 + Math.random() * 0.001; // Slightly varied speed self.wobbleAmount = 0.05 + Math.random() * 0.02; // 5-7 degrees in radians self.wobbleDirection = Math.random() > 0.5 ? 1 : -1; // Random initial direction self.baseRotation = 0; self.update = function () { if (!self.collected) { // Calculate wobble using sine wave var time = Date.now() * self.wobbleSpeed + self.wobbleOffset; self.gfx.rotation = self.baseRotation + Math.sin(time) * self.wobbleAmount * self.wobbleDirection; } }; self.collect = function () { if (!self.collected) { self.collected = true; tween(self, { scaleX: 1.5, scaleY: 1.5, alpha: 0 }, { duration: 300, easing: tween.cubicOut, onComplete: function onComplete() { self.destroy(); // checkRoundCompletion(); // removed: collecting a coin no longer completes the level } }); } }; return self; }); /** * Enemy * Responsibility: Static enemy that causes game over when touched */ var Enemy = Container.expand(function () { var self = Container.call(this); self.gfx = self.attachAsset('enemy', { anchorX: 0.5, anchorY: 0.5 }); return self; }); /** * AntiCheatService * Responsibility: Manages disabling random cells from each beat layer each round */ /** * Hero * Responsibility: To model the player character. It manages its own state and animations, * but no longer triggers its own sound effects. */ var Hero = Container.expand(function () { var self = Container.call(this); self.gfx = self.attachAsset('hero', { anchorX: 0.5, anchorY: 0.5 }); self.currentCol = 0; self.gridRow = GameConstants.CHAR_ROW; // Track last direction and up-walk alternation self.lastDirection = "down"; // "left", "right", "up", "down" self.lastEat = false; self.upWalkAlt = false; // toggles for up-walk alternation // Helper to set hero frame based on direction and eating state self.setFrame = function (direction, isEating, isUpWalkAlt) { // Only update if changed var assetId = "hero"; if (direction === "left") { assetId = isEating ? "Hero_Left_Eat" : "Hero_Left_Walk"; } else if (direction === "right") { assetId = isEating ? "Hero_Right_Eat" : "Hero_Right_Walk"; } else if (direction === "up") { assetId = isUpWalkAlt ? "Hero_Back_Walk_2" : "Hero_Back_Walk"; } else if (direction === "down") { assetId = "hero"; } // Only swap if different if (!self.gfx.assetId || self.gfx.assetId !== assetId) { if (self.gfx) { self.gfx.destroy(); } self.gfx = self.attachAsset(assetId, { anchorX: 0.5, anchorY: 0.5 }); self.gfx.assetId = assetId; // Track for future checks } self.lastDirection = direction; self.lastEat = isEating; if (direction === "up") { self.upWalkAlt = isUpWalkAlt; } }; self.moveTo = function (nx, ny, duration) { tween(self, { x: nx, y: ny }, { duration: duration || 180, easing: tween.cubicOut }); }; return self; }); /** * Key * Responsibility: Static visual marker for the key */ var Key = Container.expand(function () { var self = Container.call(this); self.gfx = self.attachAsset('key', { anchorX: 0.5, anchorY: 0.5 }); return self; }); /** * Princess * Responsibility: Static visual marker for the princess goal */ var Princess = Container.expand(function () { var self = Container.call(this); self.gfx = self.attachAsset('princess', { anchorX: 0.5, anchorY: 0.5 }); return self; }); /** * Projectile * Responsibility: Projectile shot by hero, accelerates toward a target coin and collects it on hit */ var Projectile = Container.expand(function (targetCoin, onCollectCallback) { var self = Container.call(this); self.gfx = self.attachAsset('Projectile', { anchorX: 0.5, anchorY: 0.5 }); self.targetCoin = targetCoin; self.onCollectCallback = onCollectCallback; self.speed = 10; // initial speed self.maxSpeed = 50; self.acceleration = 2; self.lastX = self.x; self.lastY = self.y; self.update = function () { if (!self.targetCoin || self.targetCoin.collected) { self.destroy(); return; } // Calculate direction to target var dx = self.targetCoin.x - self.x; var dy = self.targetCoin.y - self.y; var dist = Math.sqrt(dx * dx + dy * dy); if (dist < 1) { dist = 1; } // Accelerate if (self.speed < self.maxSpeed) { self.speed += self.acceleration; if (self.speed > self.maxSpeed) { self.speed = self.maxSpeed; } } // Move towards target var moveX = dx / dist * self.speed; var moveY = dy / dist * self.speed; self.x += moveX; self.y += moveY; // Check collision (use bounding box for simplicity) if (!self.targetCoin.collected && Math.abs(self.x - self.targetCoin.x) < 40 && Math.abs(self.y - self.targetCoin.y) < 40) { // Collect the coin if (typeof self.targetCoin.collect === "function") { self.targetCoin.collect(); // **** REMOVED: onCollectCallback (checkRoundCompletion) is no longer called here **** } self.destroy(); } self.lastX = self.x; self.lastY = self.y; }; return self; }); /**** * Initialize Game ****/ /************************* * SERVICES *************************/ /** * AudioService * Responsibility: To handle all requests to play sounds and music. */ /************************* * INITIALIZE & RUN GAME *************************/ // 1. Create Game Instance var game = new LK.Game({ backgroundColor: 0x181830 }); /**** * Game Code ****/ /** * AntiCheatService * Responsibility: Manages disabling random cells from each beat layer each round */ /** * GameConstants * Responsibility: To hold all static configuration and magic numbers in one place * for easy tuning and maintenance. */ /************************* * GAME CONFIGURATION *************************/ // Plugins (Unchanged) // Assets (Unchanged) /************************* * ASSETS & PLUGINS *************************/ // 2. Instantiate All Services /************************* * CORE GAME OBJECTS *************************/ /** * GameGrid * Responsibility: Centralizes the entire grid (playable + beat area) into a single class. * Provides accessors for both the playable area and the beat grid, and manages their state. */ function AntiCheatService(gridService, gameGrid) { this.gridService = gridService; this.gameGrid = gameGrid; this.disabledCells = []; // Array to store currently disabled cells this.prevDisabledCells = []; // Track previously disabled cells for re-enabling this.cellsPerLayer = 2; // Number of cells to disable per layer this.initialize = function () { // Disable initial cells for round 1 this.disableRandomCells(); }; this.disableRandomCells = function () { // Store previously disabled cells this.prevDisabledCells = this.disabledCells.slice(); this.disabledCells = []; // Process rows in order: up (0), left (1), right (2), down (3) for (var r = 0; r < GameConstants.BEAT_ROWS; r++) { var availableCols = []; // Get all columns for this row, excluding previously disabled ones for (var c = 0; c < GameConstants.GRID_COLS; c++) { var isValid = true; // Check if this column was previously disabled for this row for (var p = 0; p < this.prevDisabledCells.length; p++) { if (this.prevDisabledCells[p].row === r && this.prevDisabledCells[p].col === c) { isValid = false; break; } } // Check minimum distance constraint from already disabled cells in this row for (var d = 0; d < this.disabledCells.length; d++) { if (this.disabledCells[d].row === r) { var distance = Math.abs(this.disabledCells[d].col - c); if (distance < 3) { // Minimum 2 tiles between disabled cells isValid = false; break; } } } // Check diagonal constraint for rows below the first if (r > 0) { // Only check the immediately previous row for diagonal constraints var prevRow = r - 1; for (var d = 0; d < this.disabledCells.length; d++) { if (this.disabledCells[d].row === prevRow) { var prevCol = this.disabledCells[d].col; // Check if this column is diagonally adjacent to a disabled cell in the previous row if (c === prevCol - 1 || c === prevCol || c === prevCol + 1) { isValid = false; break; } } } } if (isValid) { availableCols.push(c); } } // Select cells with minimum distance constraint var selectedCols = []; var attempts = 0; while (selectedCols.length < this.cellsPerLayer && availableCols.length > 0 && attempts < 100) { attempts++; // Pick a random column from available var randomIndex = Math.floor(Math.random() * availableCols.length); var col = availableCols[randomIndex]; // Check if this column maintains minimum distance from already selected var validChoice = true; for (var s = 0; s < selectedCols.length; s++) { if (Math.abs(selectedCols[s] - col) < 3) { validChoice = false; break; } } if (validChoice) { selectedCols.push(col); // Remove this column and nearby columns from available availableCols = availableCols.filter(function (c) { return Math.abs(c - col) >= 3; }); } else { // Remove this column as it's too close availableCols.splice(randomIndex, 1); } } // Disable the selected columns for (var i = 0; i < selectedCols.length; i++) { var col = selectedCols[i]; var cell = this.gameGrid.getBeatCell(r, col); if (cell) { this.disabledCells.push({ row: r, col: col, cell: cell }); // Hide the cell cell.visible = false; } } } }; this.reset = function () { // First, enable ALL cells in the beat grid for (var r = 0; r < GameConstants.BEAT_ROWS; r++) { for (var c = 0; c < GameConstants.GRID_COLS; c++) { var cell = this.gameGrid.getBeatCell(r, c); if (cell) { cell.visible = true; } } } // Clear both arrays to start fresh this.prevDisabledCells = []; this.disabledCells = []; // Disable new random cells with a completely new pattern this.disableRandomCells(); }; // Check if a cell is disabled this.isCellDisabled = function (beatRow, col) { for (var i = 0; i < this.disabledCells.length; i++) { var disabled = this.disabledCells[i]; if (disabled.row === beatRow && disabled.col === col) { return true; } } return false; }; } function GameGrid(game) { this.game = game; this.rows = GameConstants.TOTAL_ROWS; this.cols = GameConstants.GRID_COLS; this.grid = []; // 2D array: [row][col] for all rows (playable + beat) this.beatRows = GameConstants.BEAT_ROWS; this.playableRows = this.rows - this.beatRows; // Initialize grid with nulls for (var r = 0; r < this.rows; r++) { this.grid[r] = []; for (var c = 0; c < this.cols; c++) { this.grid[r][c] = null; } } // Create background visuals for all cells this.initBackground = function () { var initialCoinCount = 0; for (var r = 0; r < this.rows; r++) { for (var c = 0; c < this.cols; c++) { var bgCell = LK.getAsset('emptycell', { anchorX: 0.5, anchorY: 0.5, alpha: 0.3 }); bgCell.x = GameConstants.GRID_LEFT + c * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; bgCell.y = GameConstants.GRID_TOP + r * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; this.game.addChild(bgCell); // Add coin to all playable cells (hero will be positioned after coins are created) if (r < this.playableRows) { var coin = new Coin(); coin.x = bgCell.x; coin.y = bgCell.y; this.game.addChild(coin); this.grid[r][c] = coin; initialCoinCount++; } } } return initialCoinCount; }; // Create BeatCell objects for the beat grid (bottom rows) this.initBeatGrid = function () { for (var r = 0; r < this.beatRows; r++) { var gridRow = this.playableRows + r; for (var c = 0; c < this.cols; c++) { var cell = new BeatCell(r); cell.x = GameConstants.GRID_LEFT + c * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; cell.y = GameConstants.GRID_TOP + gridRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; this.game.addChild(cell); this.grid[gridRow][c] = cell; } } }; // Returns the BeatCell at a given (beatRow, col) in the beat grid this.getBeatCell = function (beatRow, col) { var gridRow = this.playableRows + beatRow; return this.grid[gridRow][col]; }; // Returns the BeatCell at a given (x, y) screen coordinate, or null if not in beat grid this.getBeatCellAt = function (x, y) { for (var r = 0; r < this.beatRows; r++) { var gridRow = this.playableRows + r; for (var c = 0; c < this.cols; c++) { var cell = this.grid[gridRow][c]; if (!cell) { continue; } var cx = GameConstants.GRID_LEFT + c * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; var cy = GameConstants.GRID_TOP + gridRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; if (x >= cx - GameConstants.CELL_SIZE / 2 && x <= cx + GameConstants.CELL_SIZE / 2 && y >= cy - GameConstants.CELL_SIZE / 2 && y <= cy + GameConstants.CELL_SIZE / 2) { return cell; } } } return null; }; // Returns true if (row, col) is in the playable area this.isPlayableCell = function (row, col) { return row >= 0 && row < this.playableRows && col >= 0 && col < this.cols; }; // Returns true if (row, col) is in the beat grid this.isBeatCell = function (row, col) { return row >= this.playableRows && row < this.rows && col >= 0 && col < this.cols; }; // Returns the BeatCell 2D array for the beat grid (bottom rows) this.getBeatGrid = function () { var arr = []; for (var r = 0; r < this.beatRows; r++) { arr[r] = []; for (var c = 0; c < this.cols; c++) { arr[r][c] = this.getBeatCell(r, c); } } return arr; }; // Returns the playable area as a 2D array (nulls, or game objects if you want to add them) this.getPlayableGrid = function () { var arr = []; for (var r = 0; r < this.playableRows; r++) { arr[r] = []; for (var c = 0; c < this.cols; c++) { arr[r][c] = this.grid[r][c]; } } return arr; }; // Reset the playable grid with coins in all cells (no hero logic) this.resetGridWithCoins = function () { // Clear existing coins and enemies for (var r = 0; r < this.playableRows; r++) { for (var c = 0; c < this.cols; c++) { if (this.grid[r][c]) { this.grid[r][c].destroy(); this.grid[r][c] = null; } } } // Add new coins to all playable cells var coinCount = 0; for (var r = 0; r < this.playableRows; r++) { for (var c = 0; c < this.cols; c++) { var coin = new Coin(); coin.x = GameConstants.GRID_LEFT + c * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; coin.y = GameConstants.GRID_TOP + r * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; this.game.addChild(coin); this.grid[r][c] = coin; coinCount++; } } return coinCount; }; // Call this to initialize all visuals this.initialize = function () { this.initBeatGrid(); }; } var GameConstants = { GRID_COLS: 8, TOTAL_ROWS: 10, // Reduced from 11 to remove top row BEAT_ROWS: 4, // +1 for new down row CHAR_ROW: 3, // hero now starts at row 3 (was 4) due to removed top row JUMP_ROW: 2, // up movement now at row 2 (was 3) due to removed top row DOWN_ROW: 1, // new down movement row 1 (was 2) due to removed top row GRID_MARGIN_X: 40, get CELL_SIZE() { return Math.floor((2048 - 2 * this.GRID_MARGIN_X) / this.GRID_COLS); }, get GRID_HEIGHT() { return this.TOTAL_ROWS * this.CELL_SIZE; }, get GRID_TOP() { return 2732 - this.GRID_HEIGHT - 10; }, get GRID_LEFT() { return this.GRID_MARGIN_X; }, get HERO_MIN_X() { return this.GRID_LEFT + this.CELL_SIZE / 2; }, get HERO_MAX_X() { return this.GRID_LEFT + (this.GRID_COLS - 1) * this.CELL_SIZE + this.CELL_SIZE / 2; }, get HERO_START_COL() { return Math.floor(this.GRID_COLS / 2); }, get HERO_START_X() { return this.GRID_LEFT + this.HERO_START_COL * this.CELL_SIZE + this.CELL_SIZE / 2; }, get HERO_START_Y() { return this.GRID_TOP + this.CHAR_ROW * this.CELL_SIZE + this.CELL_SIZE / 2; }, STEP_INTERVAL: 400, // ms per step (150bpm) COIN_SPAWN_BEATS: 8, COIN_SPAWN_CHANCE: 0.25 }; /************************* * SERVICES *************************/ /** * AudioService * Responsibility: To handle all requests to play sounds and music. */ var AudioService = { playBeat: function playBeat() { LK.getSound('beat').play(); }, playBeatUp: function playBeatUp() { LK.getSound('Sound_Up').play(); }, playBeatLeft: function playBeatLeft() { LK.getSound('Sound_Left').play(); }, playBeatRight: function playBeatRight() { LK.getSound('Sound_Right').play(); }, playBeatDown: function playBeatDown() { LK.getSound('Sound_Down').play(); }, playMove: function playMove() { LK.getSound('moveSnd').play(); }, playCoinCollect: function playCoinCollect() { LK.getSound('coinCollect').play(); }, playNewLevel: function playNewLevel() { LK.getSound('NewLevel').play(); }, startMusic: function startMusic() { LK.playMusic('bgmusic', { fade: { start: 0, end: 0.3, duration: 1200 } }); } }; /** * UIService * Responsibility: To create and manage all non-game-grid UI elements, such * as the score text and the step indicators. */ function UIService(game) { this.game = game; this.scoreTxt = null; this.initialize = function () { // Score Text this.scoreTxt = new Text2('0', { size: 100, fill: "#fff" }); this.scoreTxt.anchor.set(0.5, 0); LK.gui.top.addChild(this.scoreTxt); // Create columnHighlights for beat grid rows this.columnHighlights = []; for (var r = 0; r < GameConstants.BEAT_ROWS; r++) { var highlight = LK.getAsset('cellWhite', { anchorX: 0.5, anchorY: 0.5, alpha: 0 // invisible by default }); // Position highlight at the leftmost column, correct row in the beat grid var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + r; highlight.x = GameConstants.GRID_LEFT + GameConstants.CELL_SIZE / 2; highlight.y = GameConstants.GRID_TOP + gridRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; highlight.alpha = 0; // invisible by default, semi-transparent when shown this.game.addChild(highlight); this.columnHighlights.push(highlight); } // Hide all highlights at start for (var i = 0; i < this.columnHighlights.length; i++) { this.columnHighlights[i].alpha = 0; } }; this.updateScore = function (newScore) { this.scoreTxt.setText(newScore); }; // Main Highlighting Logic: highlights inactive cells in the current column this.updateColumnHighlight = function (currentColumnIndex) { // Hide all highlights first for (var i = 0; i < this.columnHighlights.length; i++) { this.columnHighlights[i].alpha = 0; } // Get active states for the current column var activeStates = gridService.getActiveStatesForColumn(currentColumnIndex); // Loop through each row in the beat grid for (var i = 0; i < GameConstants.BEAT_ROWS; i++) { if (!activeStates[i]) { // Position the highlight at the correct cell var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + i; this.columnHighlights[i].x = GameConstants.GRID_LEFT + currentColumnIndex * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; this.columnHighlights[i].y = GameConstants.GRID_TOP + gridRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; this.columnHighlights[i].alpha = 0.2; // semi-transparent highlight } } }; } /** * GridService * Responsibility: To create, manage the state of, and update the visuals of the beat grid. */ function GridService(game, gameGrid) { this.game = game; this.gameGrid = gameGrid; // Centralized grid this.initialize = function () { this.gameGrid.initialize(); }; // Returns the BeatCell at a given (x, y) screen coordinate, or null if not in beat grid this.getCellAt = function (x, y) { var cell = this.gameGrid.getBeatCellAt(x, y); // Check if cell is disabled by anti-cheat if (cell && antiCheatService) { // Find the beat row of this cell var beatRow = cell.row; var col = -1; // Find column for (var c = 0; c < GameConstants.GRID_COLS; c++) { if (this.gameGrid.getBeatCell(beatRow, c) === cell) { col = c; break; } } if (col !== -1 && antiCheatService.isCellDisabled(beatRow, col)) { return null; // Return null if cell is disabled } } return cell; }; // Returns an array of booleans representing the active state of each BeatCell in the given column this.getActiveStatesForColumn = function (columnIndex) { var states = []; for (var r = 0; r < GameConstants.BEAT_ROWS; r++) { var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + r; var cell = this.gameGrid.grid[gridRow][columnIndex]; // Check if cell is disabled by anti-cheat var isDisabled = antiCheatService && antiCheatService.isCellDisabled(r, columnIndex); states.push(cell && cell.active && !isDisabled ? true : false); } return states; }; // Toggle a beat cell, ensuring only one beat per layer per column this.toggleCell = function (cell, mode) { var needsChange = mode === 'add' && !cell.active || mode === 'remove' && cell.active; if (needsChange) { // If adding, remove any other active beat in this column (same col, different row) if (mode === 'add') { var col = null; // Find the column index of this cell for (var c = 0; c < GameConstants.GRID_COLS; c++) { // cell.row is the beat row (0..BEAT_ROWS-1), but grid is [playableRows+row][col] var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + cell.row; if (this.gameGrid.grid[gridRow][c] === cell) { col = c; break; } } if (col !== null) { for (var r = 0; r < GameConstants.BEAT_ROWS; r++) { var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + r; var otherCell = this.gameGrid.grid[gridRow][col]; if (r !== cell.row && otherCell && otherCell.active) { otherCell.setActive(false); this.updateCellVisual(otherCell); } } } } cell.toggle(); this.updateCellVisual(cell); } }; this.updateCellVisual = function (cell) { cell.gfx.destroy(); var assetId = 'cell'; var alpha = 1.0; // Always show the appropriate beat tile based on row if (cell.row === 0) { assetId = 'cellActive'; } else if (cell.row === 1) { assetId = 'cellLeft'; } else if (cell.row === 2) { assetId = 'cellRight'; } else if (cell.row === 3) { assetId = 'cellDown'; } // Set transparency based on active state if (!cell.active) { alpha = 0.1; } cell.gfx = cell.attachAsset(assetId, { anchorX: 0.5, anchorY: 0.5, alpha: alpha }); }; // Returns the actions for a given step (column) by checking the beat grid this.getActiveActionsForStep = function (step) { // The beat grid is in the bottom rows of the grid var actions = { up: false, left: false, right: false, down: false }; for (var r = 0; r < GameConstants.BEAT_ROWS; r++) { var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + r; var cell = this.gameGrid.grid[gridRow][step]; if (!cell) { continue; } // Check if cell is disabled by anti-cheat var isDisabled = antiCheatService && antiCheatService.isCellDisabled(r, step); if (r === 0) { actions.up = cell.active && !isDisabled; } else if (r === 1) { actions.left = cell.active && !isDisabled; } else if (r === 2) { actions.right = cell.active && !isDisabled; } else if (r === 3) { actions.down = cell.active && !isDisabled; } } return actions; }; } /** * PlayerService * Responsibility: To create and manage the Hero instance, its state, and its actions. * It translates logical actions ("move left") into animations and sound calls. */ function PlayerService(game) { this.game = game; this.hero = null; this.initialize = function () { this.hero = new Hero(); // Select random starting position in playable grid var randomRow = Math.floor(Math.random() * gameGrid.playableRows); var randomCol = Math.floor(Math.random() * GameConstants.GRID_COLS); // Calculate position based on random grid coordinates this.hero.x = GameConstants.GRID_LEFT + randomCol * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; this.hero.y = GameConstants.GRID_TOP + randomRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; this.hero.currentCol = randomCol; this.hero.gridRow = randomRow; // Update CHAR_ROW to match random starting row GameConstants.CHAR_ROW = randomRow; this.game.addChild(this.hero); // Remove coin at hero's starting position if one exists var coin = gameGrid.grid[randomRow][randomCol]; if (coin && coin.collect) { coin.destroy(); gameGrid.grid[randomRow][randomCol] = null; totalCoins--; // Decrease initial coin count } }; this.getHeroInstance = function () { return this.hero; }; this.handleDrag = function (x) { this.hero.x = Math.max(GameConstants.HERO_MIN_X, Math.min(GameConstants.HERO_MAX_X, x)); }; this.performActions = function (actions) { var didAction = false; var horizontalMove = 0; var hero = this.hero; var direction = "down"; var isEating = false; var isUpWalkAlt = hero.upWalkAlt || false; if (actions.left && !actions.right) { horizontalMove = -1; direction = "left"; } if (actions.right && !actions.left) { horizontalMove = 1; direction = "right"; } if (actions.up && !actions.down) { direction = "up"; // Alternate up-walk frame isUpWalkAlt = !hero.upWalkAlt; if (horizontalMove !== 0) { this.move(horizontalMove); } this.moveVertical(-1); // Move up didAction = true; } else if (actions.down && !actions.up) { direction = "down"; hero.upWalkAlt = false; // Reset alternation on down if (horizontalMove !== 0) { this.move(horizontalMove); } this.moveVertical(1); // Move down didAction = true; } else if (horizontalMove !== 0) { // Only horizontal direction = horizontalMove === -1 ? "left" : "right"; hero.upWalkAlt = false; // Reset alternation on horizontal this.move(horizontalMove); didAction = true; } // Set hero frame for movement (not eating) hero.setFrame(direction, false, isUpWalkAlt); if (didAction) { // Pop animation: scale up to 1.18, then back to 1.0 tween.stop(hero, { scaleX: true, scaleY: true }); hero.scaleX = 1.0; hero.scaleY = 1.0; tween(hero, { scaleX: 1.1, scaleY: 1.1 }, { duration: 75, easing: tween.cubicOut, onFinish: function onFinish() { tween(hero, { scaleX: 1.0, scaleY: 1.0 }, { duration: 125, easing: tween.cubicIn }); } }); AudioService.playBeat(); } }; this.move = function (direction) { var newCol = this.hero.currentCol + direction; if (newCol < 0) { newCol = GameConstants.GRID_COLS - 1; } if (newCol >= GameConstants.GRID_COLS) { newCol = 0; } this.hero.currentCol = newCol; var nx = GameConstants.GRID_LEFT + newCol * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; this.hero.moveTo(nx, this.hero.y, 180); AudioService.playMove(); }; // Move up or down by 1 row, with vertical teleport at edges of playable area this.moveVertical = function (direction) { var minRow = 0; var maxRow = gameGrid.playableRows - 1; // Only teleport within playable area (rows 0-5 after removing top row) var newRow = this.hero.gridRow !== undefined ? this.hero.gridRow : GameConstants.CHAR_ROW; newRow += direction; // Teleport vertically if out of bounds if (newRow < minRow) { newRow = maxRow; } else if (newRow > maxRow) { newRow = minRow; } this.hero.gridRow = newRow; var ny = GameConstants.GRID_TOP + newRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; this.hero.moveTo(this.hero.x, ny, 180); AudioService.playMove(); // Update CHAR_ROW for collision logic GameConstants.CHAR_ROW = newRow; }; } /** * InputService * Responsibility: To handle all raw user input (down, up, move) and delegate * the appropriate actions to other services (GridService, PlayerService). */ function InputService(gridService, playerService) { this.gridService = gridService; this.playerService = playerService; this.isPainting = false; this.paintMode = null; this.lastPaintedCell = null; this.dragHero = false; this.handleDown = function (x, y) { var cell = this.gridService.getCellAt(x, y); if (cell) { this.isPainting = true; this.paintMode = cell.active ? 'remove' : 'add'; this.lastPaintedCell = cell; this.gridService.toggleCell(cell, this.paintMode); } else { var hero = this.playerService.getHeroInstance(); var dx = x - hero.x, dy = y - hero.y; if (dx * dx + dy * dy < 200 * 200) { this.dragHero = true; } } }; this.handleUp = function () { this.isPainting = false; this.paintMode = null; this.lastPaintedCell = null; this.dragHero = false; }; this.handleMove = function (x, y) { if (this.isPainting) { var cell = this.gridService.getCellAt(x, y); if (cell && cell !== this.lastPaintedCell) { this.lastPaintedCell = cell; this.gridService.toggleCell(cell, this.paintMode); } } else if (this.dragHero) { this.playerService.handleDrag(x); } }; } /** * SequencerService * Responsibility: To manage the main game loop timing (the "beat"). On each step, * it orchestrates calls to the other services to update the game state. */ function SequencerService(gridService, playerService, uiService) { this.gridService = gridService; this.playerService = playerService; this.uiService = uiService; this.currentStep = 0; this.lastStepTime = 0; this.playing = true; this.comboMultiplier = 1; this.collectedThisBeat = false; this.prevComboMultiplier = 1; // Previous Fibonacci number this.comboCount = 0; // Track position in Fibonacci sequence this.enemyCellRow = undefined; // Track enemy cell position this.enemyCellCol = undefined; // Track enemy cell position this.initialize = function () { this.lastStepTime = Date.now(); }; this.update = function () { var now = Date.now(); if (this.playing && now - this.lastStepTime >= GameConstants.STEP_INTERVAL) { this.lastStepTime = now; // 1. UPDATE HIGHLIGHT FIRST for the current step this.uiService.updateColumnHighlight(this.currentStep); // 2. THEN, advance the step to the next beat this.currentStep = (this.currentStep + 1) % GameConstants.GRID_COLS; // 3. FINALLY, perform actions for the new step. this.doStepActions(); } }; this.doStepActions = function () { // Reset collection flag for this beat this.collectedThisBeat = false; // Get actions for the PREVIOUS step (the one the highlight just left) for audio/animation var previousStep = (this.currentStep - 1 + GameConstants.GRID_COLS) % GameConstants.GRID_COLS; var previousActions = this.gridService.getActiveActionsForStep(previousStep); // Play beat sounds for the previous step if (previousActions.up) { AudioService.playBeatUp(); } else if (previousActions.left) { AudioService.playBeatLeft(); } else if (previousActions.right) { AudioService.playBeatRight(); } else if (previousActions.down) { AudioService.playBeatDown(); } else { AudioService.playBeat(); } // Animate active beat cells for the previous step for (var r = 0; r < GameConstants.BEAT_ROWS; r++) { var cell = gameGrid.getBeatCell(r, previousStep); if (cell && cell.active && !antiCheatService.isCellDisabled(r, previousStep)) { // Create bump animation: scale up to 110% then back to 100% tween(cell, { scaleX: 1.1, scaleY: 1.1 }, { duration: 100, easing: tween.easeOut, onFinish: function (targetCell) { return function () { tween(targetCell, { scaleX: 1.0, scaleY: 1.0 }, { duration: 100, easing: tween.easeIn }); }; }(cell) }); } } // Store hero's previous position before performing actions var hero = this.playerService.getHeroInstance(); var prevHeroRow = hero.gridRow; var prevHeroCol = hero.currentCol; // Perform actions for the CURRENT step (the one the highlight is on) var currentActions = this.gridService.getActiveActionsForStep(this.currentStep); this.playerService.performActions(currentActions); // Check if hero was on an enemy cell and has now moved away if (this.enemyCellRow !== undefined && this.enemyCellCol !== undefined) { // Check if hero has moved to a different cell if (hero.gridRow !== this.enemyCellRow || hero.currentCol !== this.enemyCellCol) { // Hero has left the enemy cell - trigger game over LK.showGameOver(); return; // Exit early } } // Check for coin collection, key, princess, or enemy collision at current position var heroRow = hero.gridRow; var heroCol = hero.currentCol; var entity = gameGrid.grid[heroRow][heroCol]; if (entity) { // --- Key logic --- if (entity instanceof Key) { playerHasKey = true; AudioService.playNewLevel(); // Play a special sound for key collection (reuse NewLevel for now) entity.destroy(); gameGrid.grid[heroRow][heroCol] = null; } // --- Princess logic --- else if (entity instanceof Princess) { if (playerHasKey) { // Player wins the round! Start next round after a short delay LK.setTimeout(startNextRound, 600); return; } // If player doesn't have key, do nothing (can't win yet) } // --- Enemy logic --- else if (!entity.collect) { // Enemy detected - store the position but don't trigger game over yet this.enemyCellRow = heroRow; this.enemyCellCol = heroCol; } // --- Coin logic --- else if (entity.collect) { // It's a coin - clear any enemy tracking this.enemyCellRow = undefined; this.enemyCellCol = undefined; entity.collect(); gameGrid.grid[heroRow][heroCol] = null; // Decrement projectile counter and fire projectile if needed coinsToNextProjectile--; if (coinsToNextProjectile <= 0) { // Find all uncollected coins on the grid var availableCoins = []; for (var r = 0; r < gameGrid.playableRows; r++) { for (var c = 0; c < GameConstants.GRID_COLS; c++) { var obj = gameGrid.grid[r][c]; if (obj && obj.collect && !obj.collected) { availableCoins.push(obj); } } } if (availableCoins.length > 0) { var targetIdx = Math.floor(Math.random() * availableCoins.length); var targetCoin = availableCoins[targetIdx]; // Create projectile at hero's position, pass checkRoundCompletion as callback // Do NOT call checkRoundCompletion here! Only call it when the coin is actually destroyed by the projectile. var projectile = new Projectile(targetCoin, checkRoundCompletion); projectile.x = hero.x; projectile.y = hero.y; game.addChild(projectile); } coinsToNextProjectile = 10; // Reset counter after firing } updateProjectileCounterUI(); // Apply combo multiplier to score (incremental: 1,2,3...) var points = this.comboMultiplier; LK.setScore(LK.getScore() + points); uiService.updateScore(LK.getScore()); AudioService.playCoinCollect(); // Set hero frame to Eat variant for this direction var eatDirection = hero.lastDirection || "down"; var eatUpWalkAlt = hero.upWalkAlt || false; if (eatDirection === "down") { // Show hero_2 when eating facing camera, then revert to hero hero.gfx.destroy(); hero.gfx = hero.attachAsset('hero_2', { anchorX: 0.5, anchorY: 0.5 }); hero.gfx.assetId = "hero_2"; LK.setTimeout(function () { hero.setFrame("down", false, false); }, 299); } else { hero.setFrame(eatDirection, true, eatUpWalkAlt); // After 299ms, revert to walk frame (not eating) LK.setTimeout(function () { hero.setFrame(eatDirection, false, eatUpWalkAlt); }, 299); } // Mark that we collected a coin this beat this.collectedThisBeat = true; // Increment combo multiplier by 1 for each consecutive coin this.comboMultiplier++; this.comboCount++; // Do NOT call checkRoundCompletion here! Only call it when the coin is actually destroyed by the projectile or collected by the hero. } } else { // No entity at current position - clear any enemy tracking this.enemyCellRow = undefined; this.enemyCellCol = undefined; // No coin collected - reset combo if we had one if (this.comboMultiplier > 1) { this.comboMultiplier = 1; this.comboCount = 0; } } }; } var currentRound = 1; var totalCoins = 0; var collectedCoins = 0; var playerHasKey = false; // Track if player has the key for win condition // All round/coin setup is now handled exclusively in startNextRound. Remove any legacy setup elsewhere. // --- Projectile Counter UI --- var coinsToNextProjectile = 10; var projectileCounterTxt = new Text2("Next shot: 10", { size: 70, fill: 0xFFD700 }); projectileCounterTxt.anchor.set(1, 0); // right-top // Place in top right, but leave 100px margin for menu LK.gui.topRight.addChild(projectileCounterTxt); // Helper to update the UI text function updateProjectileCounterUI() { projectileCounterTxt.setText("Next shot: " + coinsToNextProjectile); } updateProjectileCounterUI(); // All round/coin/hero/beat grid setup is now handled in startNextRound. No legacy setup should remain. // All beat grid and coin initialization is now handled in startNextRound. Remove any legacy grid/coin setup elsewhere. /** * Resets the game state and starts the next round. */ function startNextRound() { // --- Step A: Clean the Slate --- // Destroy all old game objects (Coin, Enemy, Key, Princess) from the previous round and clear grid for (var r = 0; r < gameGrid.playableRows; r++) { for (var c = 0; c < GameConstants.GRID_COLS; c++) { var obj = gameGrid.grid[r][c]; if (obj && typeof obj.destroy === "function") { obj.destroy(); } gameGrid.grid[r][c] = null; } } // --- Step B: Reset the Rules --- currentRound++; collectedCoins = 0; playerHasKey = false; // --- Step C: Create a "Pool" of All Available Cells --- var cellPool = []; for (var r = 0; r < gameGrid.playableRows; r++) { for (var c = 0; c < GameConstants.GRID_COLS; c++) { cellPool.push({ row: r, col: c }); } } // --- Step D: Shuffle the Pool --- for (var i = cellPool.length - 1; i > 0; i--) { var j = Math.floor(Math.random() * (i + 1)); var temp = cellPool[i]; cellPool[i] = cellPool[j]; cellPool[j] = temp; } // --- Step E: Place the Special Items --- // Always (re)initialize the hero via playerService.initialize() playerService.initialize(); var hero = playerService.getHeroInstance(); // Place the Player var heroCell = cellPool.shift(); hero.gridRow = heroCell.row; hero.currentCol = heroCell.col; hero.x = GameConstants.GRID_LEFT + heroCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; hero.y = GameConstants.GRID_TOP + heroCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; GameConstants.CHAR_ROW = heroCell.row; // Place the Princess (not adjacent to hero if possible) var princessCell; for (var idx = 0; idx < cellPool.length; idx++) { var candidate = cellPool[idx]; var isAdjacent = Math.abs(candidate.row - heroCell.row) <= 1 && Math.abs(candidate.col - heroCell.col) <= 1; if (!isAdjacent) { princessCell = candidate; cellPool.splice(idx, 1); break; } } if (!princessCell) { princessCell = cellPool.shift(); } var princess = new Princess(); princess.x = GameConstants.GRID_LEFT + princessCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; princess.y = GameConstants.GRID_TOP + princessCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; game.addChild(princess); gameGrid.grid[princessCell.row][princessCell.col] = princess; // Place the Key var keyCell = cellPool.shift(); var key = new Key(); key.x = GameConstants.GRID_LEFT + keyCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; key.y = GameConstants.GRID_TOP + keyCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; game.addChild(key); gameGrid.grid[keyCell.row][keyCell.col] = key; // Place the Enemies (currentRound - 1) var numEnemies = Math.max(0, currentRound - 1); for (var e = 0; e < numEnemies && cellPool.length > 0; e++) { var enemyCell = cellPool.shift(); var enemy = new Enemy(); enemy.x = GameConstants.GRID_LEFT + enemyCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; enemy.y = GameConstants.GRID_TOP + enemyCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; game.addChild(enemy); gameGrid.grid[enemyCell.row][enemyCell.col] = enemy; } // --- Step F: Fill the Rest with Coins --- var coinCount = 0; for (var i = 0; i < cellPool.length; i++) { var cell = cellPool[i]; var coin = new Coin(); coin.x = GameConstants.GRID_LEFT + cell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; coin.y = GameConstants.GRID_TOP + cell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2; game.addChild(coin); gameGrid.grid[cell.row][cell.col] = coin; coinCount++; } // --- Step G: Final Housekeeping --- // Set totalCoins to the number of remaining cells in cellPool (i.e., the number of coins placed) totalCoins = cellPool.length; // Re-initialize the beat grid so it is visible and interactive after round setup gameGrid.initialize(); // --- Reset all beat grid cells to inactive so no beats remain active from previous level --- for (var r = 0; r < GameConstants.BEAT_ROWS; r++) { for (var c = 0; c < GameConstants.GRID_COLS; c++) { var cell = gameGrid.getBeatCell(r, c); if (cell && cell.active) { cell.setActive(false); gridService.updateCellVisual(cell); } } } antiCheatService.reset(); // Re-enable beat grid interactivity after round setup inputService.isPainting = false; inputService.paintMode = null; inputService.lastPaintedCell = null; inputService.dragHero = false; AudioService.playNewLevel(); } /** * Checks if the round is complete and triggers the next round if it is. * This function will be called whenever a coin is collected. */ function checkRoundCompletion() { // No longer used for level completion } // --- Patch coin collection logic to update counter and reset on projectile fire --- // We'll patch the doStepActions method in SequencerService below var uiService = new UIService(game); var gameGrid = new GameGrid(game); var antiCheatService = new AntiCheatService(null, gameGrid); // Initialize before gridService var gridService = new GridService(game, gameGrid); var playerService = new PlayerService(game); // Do NOT call playerService.initialize() here; hero creation is handled in startNextRound only. var inputService = new InputService(gridService, playerService); var sequencerService = new SequencerService(gridService, playerService, uiService); // 3. Initialize Services in correct order uiService.initialize(); sequencerService.initialize(); antiCheatService.initialize(); // Set currentRound to 0 so first call to startNextRound makes it 1 currentRound = 0; startNextRound(); // Remove any legacy or duplicate round/game start logic below this point to prevent double round initialization // 4. Connect Input Handlers to the Input Service game.down = function (x, y) { inputService.handleDown(x, y); }; game.up = function (x, y) { inputService.handleUp(x, y); }; game.move = function (x, y) { inputService.handleMove(x, y); }; // 5. Set Main Game Loop to the Sequencer Service game.update = function () { sequencerService.update(); }; // 6. Start background music AudioService.startMusic(); ; ; // End of file. All round/game start logic is now centralized in startNextRound.
/****
* Plugins
****/
var tween = LK.import("@upit/tween.v1");
/****
* Classes
****/
/**
* BeatCell
* Responsibility: To model the state of a single cell on the beat grid. It no longer
* handles its own graphics updates directly, this is delegated to the GridService.
*/
var BeatCell = Container.expand(function (row) {
var self = Container.call(this);
self.active = false;
self.row = row; // Know its row for external logic to interpret
// Initialize with appropriate beat tile at 20% transparency
var assetId = 'cell';
if (row === 0) {
assetId = 'cellActive';
} else if (row === 1) {
assetId = 'cellLeft';
} else if (row === 2) {
assetId = 'cellRight';
} else if (row === 3) {
assetId = 'cellDown';
}
self.gfx = self.attachAsset(assetId, {
anchorX: 0.5,
anchorY: 0.5,
alpha: 0.1
});
self.toggle = function () {
self.active = !self.active;
};
self.setActive = function (val) {
if (self.active !== val) {
self.toggle();
}
};
// The `down` event is removed and is now handled by the central InputService.
return self;
});
/**
* Coin
* Responsibility: Collectible coin that can be picked up by the hero
*/
var Coin = Container.expand(function () {
var self = Container.call(this);
self.gfx = self.attachAsset('coin', {
anchorX: 0.5,
anchorY: 0.5
});
self.collected = false;
// Add wobble animation with random offset
self.wobbleOffset = Math.random() * Math.PI * 2; // Random starting phase
self.wobbleSpeed = 0.0015 + Math.random() * 0.001; // Slightly varied speed
self.wobbleAmount = 0.05 + Math.random() * 0.02; // 5-7 degrees in radians
self.wobbleDirection = Math.random() > 0.5 ? 1 : -1; // Random initial direction
self.baseRotation = 0;
self.update = function () {
if (!self.collected) {
// Calculate wobble using sine wave
var time = Date.now() * self.wobbleSpeed + self.wobbleOffset;
self.gfx.rotation = self.baseRotation + Math.sin(time) * self.wobbleAmount * self.wobbleDirection;
}
};
self.collect = function () {
if (!self.collected) {
self.collected = true;
tween(self, {
scaleX: 1.5,
scaleY: 1.5,
alpha: 0
}, {
duration: 300,
easing: tween.cubicOut,
onComplete: function onComplete() {
self.destroy();
// checkRoundCompletion(); // removed: collecting a coin no longer completes the level
}
});
}
};
return self;
});
/**
* Enemy
* Responsibility: Static enemy that causes game over when touched
*/
var Enemy = Container.expand(function () {
var self = Container.call(this);
self.gfx = self.attachAsset('enemy', {
anchorX: 0.5,
anchorY: 0.5
});
return self;
});
/**
* AntiCheatService
* Responsibility: Manages disabling random cells from each beat layer each round
*/
/**
* Hero
* Responsibility: To model the player character. It manages its own state and animations,
* but no longer triggers its own sound effects.
*/
var Hero = Container.expand(function () {
var self = Container.call(this);
self.gfx = self.attachAsset('hero', {
anchorX: 0.5,
anchorY: 0.5
});
self.currentCol = 0;
self.gridRow = GameConstants.CHAR_ROW;
// Track last direction and up-walk alternation
self.lastDirection = "down"; // "left", "right", "up", "down"
self.lastEat = false;
self.upWalkAlt = false; // toggles for up-walk alternation
// Helper to set hero frame based on direction and eating state
self.setFrame = function (direction, isEating, isUpWalkAlt) {
// Only update if changed
var assetId = "hero";
if (direction === "left") {
assetId = isEating ? "Hero_Left_Eat" : "Hero_Left_Walk";
} else if (direction === "right") {
assetId = isEating ? "Hero_Right_Eat" : "Hero_Right_Walk";
} else if (direction === "up") {
assetId = isUpWalkAlt ? "Hero_Back_Walk_2" : "Hero_Back_Walk";
} else if (direction === "down") {
assetId = "hero";
}
// Only swap if different
if (!self.gfx.assetId || self.gfx.assetId !== assetId) {
if (self.gfx) {
self.gfx.destroy();
}
self.gfx = self.attachAsset(assetId, {
anchorX: 0.5,
anchorY: 0.5
});
self.gfx.assetId = assetId; // Track for future checks
}
self.lastDirection = direction;
self.lastEat = isEating;
if (direction === "up") {
self.upWalkAlt = isUpWalkAlt;
}
};
self.moveTo = function (nx, ny, duration) {
tween(self, {
x: nx,
y: ny
}, {
duration: duration || 180,
easing: tween.cubicOut
});
};
return self;
});
/**
* Key
* Responsibility: Static visual marker for the key
*/
var Key = Container.expand(function () {
var self = Container.call(this);
self.gfx = self.attachAsset('key', {
anchorX: 0.5,
anchorY: 0.5
});
return self;
});
/**
* Princess
* Responsibility: Static visual marker for the princess goal
*/
var Princess = Container.expand(function () {
var self = Container.call(this);
self.gfx = self.attachAsset('princess', {
anchorX: 0.5,
anchorY: 0.5
});
return self;
});
/**
* Projectile
* Responsibility: Projectile shot by hero, accelerates toward a target coin and collects it on hit
*/
var Projectile = Container.expand(function (targetCoin, onCollectCallback) {
var self = Container.call(this);
self.gfx = self.attachAsset('Projectile', {
anchorX: 0.5,
anchorY: 0.5
});
self.targetCoin = targetCoin;
self.onCollectCallback = onCollectCallback;
self.speed = 10; // initial speed
self.maxSpeed = 50;
self.acceleration = 2;
self.lastX = self.x;
self.lastY = self.y;
self.update = function () {
if (!self.targetCoin || self.targetCoin.collected) {
self.destroy();
return;
}
// Calculate direction to target
var dx = self.targetCoin.x - self.x;
var dy = self.targetCoin.y - self.y;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) {
dist = 1;
}
// Accelerate
if (self.speed < self.maxSpeed) {
self.speed += self.acceleration;
if (self.speed > self.maxSpeed) {
self.speed = self.maxSpeed;
}
}
// Move towards target
var moveX = dx / dist * self.speed;
var moveY = dy / dist * self.speed;
self.x += moveX;
self.y += moveY;
// Check collision (use bounding box for simplicity)
if (!self.targetCoin.collected && Math.abs(self.x - self.targetCoin.x) < 40 && Math.abs(self.y - self.targetCoin.y) < 40) {
// Collect the coin
if (typeof self.targetCoin.collect === "function") {
self.targetCoin.collect();
// **** REMOVED: onCollectCallback (checkRoundCompletion) is no longer called here ****
}
self.destroy();
}
self.lastX = self.x;
self.lastY = self.y;
};
return self;
});
/****
* Initialize Game
****/
/*************************
* SERVICES
*************************/
/**
* AudioService
* Responsibility: To handle all requests to play sounds and music.
*/
/*************************
* INITIALIZE & RUN GAME
*************************/
// 1. Create Game Instance
var game = new LK.Game({
backgroundColor: 0x181830
});
/****
* Game Code
****/
/**
* AntiCheatService
* Responsibility: Manages disabling random cells from each beat layer each round
*/
/**
* GameConstants
* Responsibility: To hold all static configuration and magic numbers in one place
* for easy tuning and maintenance.
*/
/*************************
* GAME CONFIGURATION
*************************/
// Plugins (Unchanged)
// Assets (Unchanged)
/*************************
* ASSETS & PLUGINS
*************************/
// 2. Instantiate All Services
/*************************
* CORE GAME OBJECTS
*************************/
/**
* GameGrid
* Responsibility: Centralizes the entire grid (playable + beat area) into a single class.
* Provides accessors for both the playable area and the beat grid, and manages their state.
*/
function AntiCheatService(gridService, gameGrid) {
this.gridService = gridService;
this.gameGrid = gameGrid;
this.disabledCells = []; // Array to store currently disabled cells
this.prevDisabledCells = []; // Track previously disabled cells for re-enabling
this.cellsPerLayer = 2; // Number of cells to disable per layer
this.initialize = function () {
// Disable initial cells for round 1
this.disableRandomCells();
};
this.disableRandomCells = function () {
// Store previously disabled cells
this.prevDisabledCells = this.disabledCells.slice();
this.disabledCells = [];
// Process rows in order: up (0), left (1), right (2), down (3)
for (var r = 0; r < GameConstants.BEAT_ROWS; r++) {
var availableCols = [];
// Get all columns for this row, excluding previously disabled ones
for (var c = 0; c < GameConstants.GRID_COLS; c++) {
var isValid = true;
// Check if this column was previously disabled for this row
for (var p = 0; p < this.prevDisabledCells.length; p++) {
if (this.prevDisabledCells[p].row === r && this.prevDisabledCells[p].col === c) {
isValid = false;
break;
}
}
// Check minimum distance constraint from already disabled cells in this row
for (var d = 0; d < this.disabledCells.length; d++) {
if (this.disabledCells[d].row === r) {
var distance = Math.abs(this.disabledCells[d].col - c);
if (distance < 3) {
// Minimum 2 tiles between disabled cells
isValid = false;
break;
}
}
}
// Check diagonal constraint for rows below the first
if (r > 0) {
// Only check the immediately previous row for diagonal constraints
var prevRow = r - 1;
for (var d = 0; d < this.disabledCells.length; d++) {
if (this.disabledCells[d].row === prevRow) {
var prevCol = this.disabledCells[d].col;
// Check if this column is diagonally adjacent to a disabled cell in the previous row
if (c === prevCol - 1 || c === prevCol || c === prevCol + 1) {
isValid = false;
break;
}
}
}
}
if (isValid) {
availableCols.push(c);
}
}
// Select cells with minimum distance constraint
var selectedCols = [];
var attempts = 0;
while (selectedCols.length < this.cellsPerLayer && availableCols.length > 0 && attempts < 100) {
attempts++;
// Pick a random column from available
var randomIndex = Math.floor(Math.random() * availableCols.length);
var col = availableCols[randomIndex];
// Check if this column maintains minimum distance from already selected
var validChoice = true;
for (var s = 0; s < selectedCols.length; s++) {
if (Math.abs(selectedCols[s] - col) < 3) {
validChoice = false;
break;
}
}
if (validChoice) {
selectedCols.push(col);
// Remove this column and nearby columns from available
availableCols = availableCols.filter(function (c) {
return Math.abs(c - col) >= 3;
});
} else {
// Remove this column as it's too close
availableCols.splice(randomIndex, 1);
}
}
// Disable the selected columns
for (var i = 0; i < selectedCols.length; i++) {
var col = selectedCols[i];
var cell = this.gameGrid.getBeatCell(r, col);
if (cell) {
this.disabledCells.push({
row: r,
col: col,
cell: cell
});
// Hide the cell
cell.visible = false;
}
}
}
};
this.reset = function () {
// First, enable ALL cells in the beat grid
for (var r = 0; r < GameConstants.BEAT_ROWS; r++) {
for (var c = 0; c < GameConstants.GRID_COLS; c++) {
var cell = this.gameGrid.getBeatCell(r, c);
if (cell) {
cell.visible = true;
}
}
}
// Clear both arrays to start fresh
this.prevDisabledCells = [];
this.disabledCells = [];
// Disable new random cells with a completely new pattern
this.disableRandomCells();
};
// Check if a cell is disabled
this.isCellDisabled = function (beatRow, col) {
for (var i = 0; i < this.disabledCells.length; i++) {
var disabled = this.disabledCells[i];
if (disabled.row === beatRow && disabled.col === col) {
return true;
}
}
return false;
};
}
function GameGrid(game) {
this.game = game;
this.rows = GameConstants.TOTAL_ROWS;
this.cols = GameConstants.GRID_COLS;
this.grid = []; // 2D array: [row][col] for all rows (playable + beat)
this.beatRows = GameConstants.BEAT_ROWS;
this.playableRows = this.rows - this.beatRows;
// Initialize grid with nulls
for (var r = 0; r < this.rows; r++) {
this.grid[r] = [];
for (var c = 0; c < this.cols; c++) {
this.grid[r][c] = null;
}
}
// Create background visuals for all cells
this.initBackground = function () {
var initialCoinCount = 0;
for (var r = 0; r < this.rows; r++) {
for (var c = 0; c < this.cols; c++) {
var bgCell = LK.getAsset('emptycell', {
anchorX: 0.5,
anchorY: 0.5,
alpha: 0.3
});
bgCell.x = GameConstants.GRID_LEFT + c * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
bgCell.y = GameConstants.GRID_TOP + r * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
this.game.addChild(bgCell);
// Add coin to all playable cells (hero will be positioned after coins are created)
if (r < this.playableRows) {
var coin = new Coin();
coin.x = bgCell.x;
coin.y = bgCell.y;
this.game.addChild(coin);
this.grid[r][c] = coin;
initialCoinCount++;
}
}
}
return initialCoinCount;
};
// Create BeatCell objects for the beat grid (bottom rows)
this.initBeatGrid = function () {
for (var r = 0; r < this.beatRows; r++) {
var gridRow = this.playableRows + r;
for (var c = 0; c < this.cols; c++) {
var cell = new BeatCell(r);
cell.x = GameConstants.GRID_LEFT + c * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
cell.y = GameConstants.GRID_TOP + gridRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
this.game.addChild(cell);
this.grid[gridRow][c] = cell;
}
}
};
// Returns the BeatCell at a given (beatRow, col) in the beat grid
this.getBeatCell = function (beatRow, col) {
var gridRow = this.playableRows + beatRow;
return this.grid[gridRow][col];
};
// Returns the BeatCell at a given (x, y) screen coordinate, or null if not in beat grid
this.getBeatCellAt = function (x, y) {
for (var r = 0; r < this.beatRows; r++) {
var gridRow = this.playableRows + r;
for (var c = 0; c < this.cols; c++) {
var cell = this.grid[gridRow][c];
if (!cell) {
continue;
}
var cx = GameConstants.GRID_LEFT + c * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
var cy = GameConstants.GRID_TOP + gridRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
if (x >= cx - GameConstants.CELL_SIZE / 2 && x <= cx + GameConstants.CELL_SIZE / 2 && y >= cy - GameConstants.CELL_SIZE / 2 && y <= cy + GameConstants.CELL_SIZE / 2) {
return cell;
}
}
}
return null;
};
// Returns true if (row, col) is in the playable area
this.isPlayableCell = function (row, col) {
return row >= 0 && row < this.playableRows && col >= 0 && col < this.cols;
};
// Returns true if (row, col) is in the beat grid
this.isBeatCell = function (row, col) {
return row >= this.playableRows && row < this.rows && col >= 0 && col < this.cols;
};
// Returns the BeatCell 2D array for the beat grid (bottom rows)
this.getBeatGrid = function () {
var arr = [];
for (var r = 0; r < this.beatRows; r++) {
arr[r] = [];
for (var c = 0; c < this.cols; c++) {
arr[r][c] = this.getBeatCell(r, c);
}
}
return arr;
};
// Returns the playable area as a 2D array (nulls, or game objects if you want to add them)
this.getPlayableGrid = function () {
var arr = [];
for (var r = 0; r < this.playableRows; r++) {
arr[r] = [];
for (var c = 0; c < this.cols; c++) {
arr[r][c] = this.grid[r][c];
}
}
return arr;
};
// Reset the playable grid with coins in all cells (no hero logic)
this.resetGridWithCoins = function () {
// Clear existing coins and enemies
for (var r = 0; r < this.playableRows; r++) {
for (var c = 0; c < this.cols; c++) {
if (this.grid[r][c]) {
this.grid[r][c].destroy();
this.grid[r][c] = null;
}
}
}
// Add new coins to all playable cells
var coinCount = 0;
for (var r = 0; r < this.playableRows; r++) {
for (var c = 0; c < this.cols; c++) {
var coin = new Coin();
coin.x = GameConstants.GRID_LEFT + c * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
coin.y = GameConstants.GRID_TOP + r * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
this.game.addChild(coin);
this.grid[r][c] = coin;
coinCount++;
}
}
return coinCount;
};
// Call this to initialize all visuals
this.initialize = function () {
this.initBeatGrid();
};
}
var GameConstants = {
GRID_COLS: 8,
TOTAL_ROWS: 10,
// Reduced from 11 to remove top row
BEAT_ROWS: 4,
// +1 for new down row
CHAR_ROW: 3,
// hero now starts at row 3 (was 4) due to removed top row
JUMP_ROW: 2,
// up movement now at row 2 (was 3) due to removed top row
DOWN_ROW: 1,
// new down movement row 1 (was 2) due to removed top row
GRID_MARGIN_X: 40,
get CELL_SIZE() {
return Math.floor((2048 - 2 * this.GRID_MARGIN_X) / this.GRID_COLS);
},
get GRID_HEIGHT() {
return this.TOTAL_ROWS * this.CELL_SIZE;
},
get GRID_TOP() {
return 2732 - this.GRID_HEIGHT - 10;
},
get GRID_LEFT() {
return this.GRID_MARGIN_X;
},
get HERO_MIN_X() {
return this.GRID_LEFT + this.CELL_SIZE / 2;
},
get HERO_MAX_X() {
return this.GRID_LEFT + (this.GRID_COLS - 1) * this.CELL_SIZE + this.CELL_SIZE / 2;
},
get HERO_START_COL() {
return Math.floor(this.GRID_COLS / 2);
},
get HERO_START_X() {
return this.GRID_LEFT + this.HERO_START_COL * this.CELL_SIZE + this.CELL_SIZE / 2;
},
get HERO_START_Y() {
return this.GRID_TOP + this.CHAR_ROW * this.CELL_SIZE + this.CELL_SIZE / 2;
},
STEP_INTERVAL: 400,
// ms per step (150bpm)
COIN_SPAWN_BEATS: 8,
COIN_SPAWN_CHANCE: 0.25
};
/*************************
* SERVICES
*************************/
/**
* AudioService
* Responsibility: To handle all requests to play sounds and music.
*/
var AudioService = {
playBeat: function playBeat() {
LK.getSound('beat').play();
},
playBeatUp: function playBeatUp() {
LK.getSound('Sound_Up').play();
},
playBeatLeft: function playBeatLeft() {
LK.getSound('Sound_Left').play();
},
playBeatRight: function playBeatRight() {
LK.getSound('Sound_Right').play();
},
playBeatDown: function playBeatDown() {
LK.getSound('Sound_Down').play();
},
playMove: function playMove() {
LK.getSound('moveSnd').play();
},
playCoinCollect: function playCoinCollect() {
LK.getSound('coinCollect').play();
},
playNewLevel: function playNewLevel() {
LK.getSound('NewLevel').play();
},
startMusic: function startMusic() {
LK.playMusic('bgmusic', {
fade: {
start: 0,
end: 0.3,
duration: 1200
}
});
}
};
/**
* UIService
* Responsibility: To create and manage all non-game-grid UI elements, such
* as the score text and the step indicators.
*/
function UIService(game) {
this.game = game;
this.scoreTxt = null;
this.initialize = function () {
// Score Text
this.scoreTxt = new Text2('0', {
size: 100,
fill: "#fff"
});
this.scoreTxt.anchor.set(0.5, 0);
LK.gui.top.addChild(this.scoreTxt);
// Create columnHighlights for beat grid rows
this.columnHighlights = [];
for (var r = 0; r < GameConstants.BEAT_ROWS; r++) {
var highlight = LK.getAsset('cellWhite', {
anchorX: 0.5,
anchorY: 0.5,
alpha: 0 // invisible by default
});
// Position highlight at the leftmost column, correct row in the beat grid
var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + r;
highlight.x = GameConstants.GRID_LEFT + GameConstants.CELL_SIZE / 2;
highlight.y = GameConstants.GRID_TOP + gridRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
highlight.alpha = 0; // invisible by default, semi-transparent when shown
this.game.addChild(highlight);
this.columnHighlights.push(highlight);
}
// Hide all highlights at start
for (var i = 0; i < this.columnHighlights.length; i++) {
this.columnHighlights[i].alpha = 0;
}
};
this.updateScore = function (newScore) {
this.scoreTxt.setText(newScore);
};
// Main Highlighting Logic: highlights inactive cells in the current column
this.updateColumnHighlight = function (currentColumnIndex) {
// Hide all highlights first
for (var i = 0; i < this.columnHighlights.length; i++) {
this.columnHighlights[i].alpha = 0;
}
// Get active states for the current column
var activeStates = gridService.getActiveStatesForColumn(currentColumnIndex);
// Loop through each row in the beat grid
for (var i = 0; i < GameConstants.BEAT_ROWS; i++) {
if (!activeStates[i]) {
// Position the highlight at the correct cell
var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + i;
this.columnHighlights[i].x = GameConstants.GRID_LEFT + currentColumnIndex * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
this.columnHighlights[i].y = GameConstants.GRID_TOP + gridRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
this.columnHighlights[i].alpha = 0.2; // semi-transparent highlight
}
}
};
}
/**
* GridService
* Responsibility: To create, manage the state of, and update the visuals of the beat grid.
*/
function GridService(game, gameGrid) {
this.game = game;
this.gameGrid = gameGrid; // Centralized grid
this.initialize = function () {
this.gameGrid.initialize();
};
// Returns the BeatCell at a given (x, y) screen coordinate, or null if not in beat grid
this.getCellAt = function (x, y) {
var cell = this.gameGrid.getBeatCellAt(x, y);
// Check if cell is disabled by anti-cheat
if (cell && antiCheatService) {
// Find the beat row of this cell
var beatRow = cell.row;
var col = -1;
// Find column
for (var c = 0; c < GameConstants.GRID_COLS; c++) {
if (this.gameGrid.getBeatCell(beatRow, c) === cell) {
col = c;
break;
}
}
if (col !== -1 && antiCheatService.isCellDisabled(beatRow, col)) {
return null; // Return null if cell is disabled
}
}
return cell;
};
// Returns an array of booleans representing the active state of each BeatCell in the given column
this.getActiveStatesForColumn = function (columnIndex) {
var states = [];
for (var r = 0; r < GameConstants.BEAT_ROWS; r++) {
var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + r;
var cell = this.gameGrid.grid[gridRow][columnIndex];
// Check if cell is disabled by anti-cheat
var isDisabled = antiCheatService && antiCheatService.isCellDisabled(r, columnIndex);
states.push(cell && cell.active && !isDisabled ? true : false);
}
return states;
};
// Toggle a beat cell, ensuring only one beat per layer per column
this.toggleCell = function (cell, mode) {
var needsChange = mode === 'add' && !cell.active || mode === 'remove' && cell.active;
if (needsChange) {
// If adding, remove any other active beat in this column (same col, different row)
if (mode === 'add') {
var col = null;
// Find the column index of this cell
for (var c = 0; c < GameConstants.GRID_COLS; c++) {
// cell.row is the beat row (0..BEAT_ROWS-1), but grid is [playableRows+row][col]
var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + cell.row;
if (this.gameGrid.grid[gridRow][c] === cell) {
col = c;
break;
}
}
if (col !== null) {
for (var r = 0; r < GameConstants.BEAT_ROWS; r++) {
var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + r;
var otherCell = this.gameGrid.grid[gridRow][col];
if (r !== cell.row && otherCell && otherCell.active) {
otherCell.setActive(false);
this.updateCellVisual(otherCell);
}
}
}
}
cell.toggle();
this.updateCellVisual(cell);
}
};
this.updateCellVisual = function (cell) {
cell.gfx.destroy();
var assetId = 'cell';
var alpha = 1.0;
// Always show the appropriate beat tile based on row
if (cell.row === 0) {
assetId = 'cellActive';
} else if (cell.row === 1) {
assetId = 'cellLeft';
} else if (cell.row === 2) {
assetId = 'cellRight';
} else if (cell.row === 3) {
assetId = 'cellDown';
}
// Set transparency based on active state
if (!cell.active) {
alpha = 0.1;
}
cell.gfx = cell.attachAsset(assetId, {
anchorX: 0.5,
anchorY: 0.5,
alpha: alpha
});
};
// Returns the actions for a given step (column) by checking the beat grid
this.getActiveActionsForStep = function (step) {
// The beat grid is in the bottom rows of the grid
var actions = {
up: false,
left: false,
right: false,
down: false
};
for (var r = 0; r < GameConstants.BEAT_ROWS; r++) {
var gridRow = GameConstants.TOTAL_ROWS - GameConstants.BEAT_ROWS + r;
var cell = this.gameGrid.grid[gridRow][step];
if (!cell) {
continue;
}
// Check if cell is disabled by anti-cheat
var isDisabled = antiCheatService && antiCheatService.isCellDisabled(r, step);
if (r === 0) {
actions.up = cell.active && !isDisabled;
} else if (r === 1) {
actions.left = cell.active && !isDisabled;
} else if (r === 2) {
actions.right = cell.active && !isDisabled;
} else if (r === 3) {
actions.down = cell.active && !isDisabled;
}
}
return actions;
};
}
/**
* PlayerService
* Responsibility: To create and manage the Hero instance, its state, and its actions.
* It translates logical actions ("move left") into animations and sound calls.
*/
function PlayerService(game) {
this.game = game;
this.hero = null;
this.initialize = function () {
this.hero = new Hero();
// Select random starting position in playable grid
var randomRow = Math.floor(Math.random() * gameGrid.playableRows);
var randomCol = Math.floor(Math.random() * GameConstants.GRID_COLS);
// Calculate position based on random grid coordinates
this.hero.x = GameConstants.GRID_LEFT + randomCol * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
this.hero.y = GameConstants.GRID_TOP + randomRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
this.hero.currentCol = randomCol;
this.hero.gridRow = randomRow;
// Update CHAR_ROW to match random starting row
GameConstants.CHAR_ROW = randomRow;
this.game.addChild(this.hero);
// Remove coin at hero's starting position if one exists
var coin = gameGrid.grid[randomRow][randomCol];
if (coin && coin.collect) {
coin.destroy();
gameGrid.grid[randomRow][randomCol] = null;
totalCoins--; // Decrease initial coin count
}
};
this.getHeroInstance = function () {
return this.hero;
};
this.handleDrag = function (x) {
this.hero.x = Math.max(GameConstants.HERO_MIN_X, Math.min(GameConstants.HERO_MAX_X, x));
};
this.performActions = function (actions) {
var didAction = false;
var horizontalMove = 0;
var hero = this.hero;
var direction = "down";
var isEating = false;
var isUpWalkAlt = hero.upWalkAlt || false;
if (actions.left && !actions.right) {
horizontalMove = -1;
direction = "left";
}
if (actions.right && !actions.left) {
horizontalMove = 1;
direction = "right";
}
if (actions.up && !actions.down) {
direction = "up";
// Alternate up-walk frame
isUpWalkAlt = !hero.upWalkAlt;
if (horizontalMove !== 0) {
this.move(horizontalMove);
}
this.moveVertical(-1); // Move up
didAction = true;
} else if (actions.down && !actions.up) {
direction = "down";
hero.upWalkAlt = false; // Reset alternation on down
if (horizontalMove !== 0) {
this.move(horizontalMove);
}
this.moveVertical(1); // Move down
didAction = true;
} else if (horizontalMove !== 0) {
// Only horizontal
direction = horizontalMove === -1 ? "left" : "right";
hero.upWalkAlt = false; // Reset alternation on horizontal
this.move(horizontalMove);
didAction = true;
}
// Set hero frame for movement (not eating)
hero.setFrame(direction, false, isUpWalkAlt);
if (didAction) {
// Pop animation: scale up to 1.18, then back to 1.0
tween.stop(hero, {
scaleX: true,
scaleY: true
});
hero.scaleX = 1.0;
hero.scaleY = 1.0;
tween(hero, {
scaleX: 1.1,
scaleY: 1.1
}, {
duration: 75,
easing: tween.cubicOut,
onFinish: function onFinish() {
tween(hero, {
scaleX: 1.0,
scaleY: 1.0
}, {
duration: 125,
easing: tween.cubicIn
});
}
});
AudioService.playBeat();
}
};
this.move = function (direction) {
var newCol = this.hero.currentCol + direction;
if (newCol < 0) {
newCol = GameConstants.GRID_COLS - 1;
}
if (newCol >= GameConstants.GRID_COLS) {
newCol = 0;
}
this.hero.currentCol = newCol;
var nx = GameConstants.GRID_LEFT + newCol * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
this.hero.moveTo(nx, this.hero.y, 180);
AudioService.playMove();
};
// Move up or down by 1 row, with vertical teleport at edges of playable area
this.moveVertical = function (direction) {
var minRow = 0;
var maxRow = gameGrid.playableRows - 1; // Only teleport within playable area (rows 0-5 after removing top row)
var newRow = this.hero.gridRow !== undefined ? this.hero.gridRow : GameConstants.CHAR_ROW;
newRow += direction;
// Teleport vertically if out of bounds
if (newRow < minRow) {
newRow = maxRow;
} else if (newRow > maxRow) {
newRow = minRow;
}
this.hero.gridRow = newRow;
var ny = GameConstants.GRID_TOP + newRow * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
this.hero.moveTo(this.hero.x, ny, 180);
AudioService.playMove();
// Update CHAR_ROW for collision logic
GameConstants.CHAR_ROW = newRow;
};
}
/**
* InputService
* Responsibility: To handle all raw user input (down, up, move) and delegate
* the appropriate actions to other services (GridService, PlayerService).
*/
function InputService(gridService, playerService) {
this.gridService = gridService;
this.playerService = playerService;
this.isPainting = false;
this.paintMode = null;
this.lastPaintedCell = null;
this.dragHero = false;
this.handleDown = function (x, y) {
var cell = this.gridService.getCellAt(x, y);
if (cell) {
this.isPainting = true;
this.paintMode = cell.active ? 'remove' : 'add';
this.lastPaintedCell = cell;
this.gridService.toggleCell(cell, this.paintMode);
} else {
var hero = this.playerService.getHeroInstance();
var dx = x - hero.x,
dy = y - hero.y;
if (dx * dx + dy * dy < 200 * 200) {
this.dragHero = true;
}
}
};
this.handleUp = function () {
this.isPainting = false;
this.paintMode = null;
this.lastPaintedCell = null;
this.dragHero = false;
};
this.handleMove = function (x, y) {
if (this.isPainting) {
var cell = this.gridService.getCellAt(x, y);
if (cell && cell !== this.lastPaintedCell) {
this.lastPaintedCell = cell;
this.gridService.toggleCell(cell, this.paintMode);
}
} else if (this.dragHero) {
this.playerService.handleDrag(x);
}
};
}
/**
* SequencerService
* Responsibility: To manage the main game loop timing (the "beat"). On each step,
* it orchestrates calls to the other services to update the game state.
*/
function SequencerService(gridService, playerService, uiService) {
this.gridService = gridService;
this.playerService = playerService;
this.uiService = uiService;
this.currentStep = 0;
this.lastStepTime = 0;
this.playing = true;
this.comboMultiplier = 1;
this.collectedThisBeat = false;
this.prevComboMultiplier = 1; // Previous Fibonacci number
this.comboCount = 0; // Track position in Fibonacci sequence
this.enemyCellRow = undefined; // Track enemy cell position
this.enemyCellCol = undefined; // Track enemy cell position
this.initialize = function () {
this.lastStepTime = Date.now();
};
this.update = function () {
var now = Date.now();
if (this.playing && now - this.lastStepTime >= GameConstants.STEP_INTERVAL) {
this.lastStepTime = now;
// 1. UPDATE HIGHLIGHT FIRST for the current step
this.uiService.updateColumnHighlight(this.currentStep);
// 2. THEN, advance the step to the next beat
this.currentStep = (this.currentStep + 1) % GameConstants.GRID_COLS;
// 3. FINALLY, perform actions for the new step.
this.doStepActions();
}
};
this.doStepActions = function () {
// Reset collection flag for this beat
this.collectedThisBeat = false;
// Get actions for the PREVIOUS step (the one the highlight just left) for audio/animation
var previousStep = (this.currentStep - 1 + GameConstants.GRID_COLS) % GameConstants.GRID_COLS;
var previousActions = this.gridService.getActiveActionsForStep(previousStep);
// Play beat sounds for the previous step
if (previousActions.up) {
AudioService.playBeatUp();
} else if (previousActions.left) {
AudioService.playBeatLeft();
} else if (previousActions.right) {
AudioService.playBeatRight();
} else if (previousActions.down) {
AudioService.playBeatDown();
} else {
AudioService.playBeat();
}
// Animate active beat cells for the previous step
for (var r = 0; r < GameConstants.BEAT_ROWS; r++) {
var cell = gameGrid.getBeatCell(r, previousStep);
if (cell && cell.active && !antiCheatService.isCellDisabled(r, previousStep)) {
// Create bump animation: scale up to 110% then back to 100%
tween(cell, {
scaleX: 1.1,
scaleY: 1.1
}, {
duration: 100,
easing: tween.easeOut,
onFinish: function (targetCell) {
return function () {
tween(targetCell, {
scaleX: 1.0,
scaleY: 1.0
}, {
duration: 100,
easing: tween.easeIn
});
};
}(cell)
});
}
}
// Store hero's previous position before performing actions
var hero = this.playerService.getHeroInstance();
var prevHeroRow = hero.gridRow;
var prevHeroCol = hero.currentCol;
// Perform actions for the CURRENT step (the one the highlight is on)
var currentActions = this.gridService.getActiveActionsForStep(this.currentStep);
this.playerService.performActions(currentActions);
// Check if hero was on an enemy cell and has now moved away
if (this.enemyCellRow !== undefined && this.enemyCellCol !== undefined) {
// Check if hero has moved to a different cell
if (hero.gridRow !== this.enemyCellRow || hero.currentCol !== this.enemyCellCol) {
// Hero has left the enemy cell - trigger game over
LK.showGameOver();
return; // Exit early
}
}
// Check for coin collection, key, princess, or enemy collision at current position
var heroRow = hero.gridRow;
var heroCol = hero.currentCol;
var entity = gameGrid.grid[heroRow][heroCol];
if (entity) {
// --- Key logic ---
if (entity instanceof Key) {
playerHasKey = true;
AudioService.playNewLevel(); // Play a special sound for key collection (reuse NewLevel for now)
entity.destroy();
gameGrid.grid[heroRow][heroCol] = null;
}
// --- Princess logic ---
else if (entity instanceof Princess) {
if (playerHasKey) {
// Player wins the round! Start next round after a short delay
LK.setTimeout(startNextRound, 600);
return;
}
// If player doesn't have key, do nothing (can't win yet)
}
// --- Enemy logic ---
else if (!entity.collect) {
// Enemy detected - store the position but don't trigger game over yet
this.enemyCellRow = heroRow;
this.enemyCellCol = heroCol;
}
// --- Coin logic ---
else if (entity.collect) {
// It's a coin - clear any enemy tracking
this.enemyCellRow = undefined;
this.enemyCellCol = undefined;
entity.collect();
gameGrid.grid[heroRow][heroCol] = null;
// Decrement projectile counter and fire projectile if needed
coinsToNextProjectile--;
if (coinsToNextProjectile <= 0) {
// Find all uncollected coins on the grid
var availableCoins = [];
for (var r = 0; r < gameGrid.playableRows; r++) {
for (var c = 0; c < GameConstants.GRID_COLS; c++) {
var obj = gameGrid.grid[r][c];
if (obj && obj.collect && !obj.collected) {
availableCoins.push(obj);
}
}
}
if (availableCoins.length > 0) {
var targetIdx = Math.floor(Math.random() * availableCoins.length);
var targetCoin = availableCoins[targetIdx];
// Create projectile at hero's position, pass checkRoundCompletion as callback
// Do NOT call checkRoundCompletion here! Only call it when the coin is actually destroyed by the projectile.
var projectile = new Projectile(targetCoin, checkRoundCompletion);
projectile.x = hero.x;
projectile.y = hero.y;
game.addChild(projectile);
}
coinsToNextProjectile = 10; // Reset counter after firing
}
updateProjectileCounterUI();
// Apply combo multiplier to score (incremental: 1,2,3...)
var points = this.comboMultiplier;
LK.setScore(LK.getScore() + points);
uiService.updateScore(LK.getScore());
AudioService.playCoinCollect();
// Set hero frame to Eat variant for this direction
var eatDirection = hero.lastDirection || "down";
var eatUpWalkAlt = hero.upWalkAlt || false;
if (eatDirection === "down") {
// Show hero_2 when eating facing camera, then revert to hero
hero.gfx.destroy();
hero.gfx = hero.attachAsset('hero_2', {
anchorX: 0.5,
anchorY: 0.5
});
hero.gfx.assetId = "hero_2";
LK.setTimeout(function () {
hero.setFrame("down", false, false);
}, 299);
} else {
hero.setFrame(eatDirection, true, eatUpWalkAlt);
// After 299ms, revert to walk frame (not eating)
LK.setTimeout(function () {
hero.setFrame(eatDirection, false, eatUpWalkAlt);
}, 299);
}
// Mark that we collected a coin this beat
this.collectedThisBeat = true;
// Increment combo multiplier by 1 for each consecutive coin
this.comboMultiplier++;
this.comboCount++;
// Do NOT call checkRoundCompletion here! Only call it when the coin is actually destroyed by the projectile or collected by the hero.
}
} else {
// No entity at current position - clear any enemy tracking
this.enemyCellRow = undefined;
this.enemyCellCol = undefined;
// No coin collected - reset combo if we had one
if (this.comboMultiplier > 1) {
this.comboMultiplier = 1;
this.comboCount = 0;
}
}
};
}
var currentRound = 1;
var totalCoins = 0;
var collectedCoins = 0;
var playerHasKey = false; // Track if player has the key for win condition
// All round/coin setup is now handled exclusively in startNextRound. Remove any legacy setup elsewhere.
// --- Projectile Counter UI ---
var coinsToNextProjectile = 10;
var projectileCounterTxt = new Text2("Next shot: 10", {
size: 70,
fill: 0xFFD700
});
projectileCounterTxt.anchor.set(1, 0); // right-top
// Place in top right, but leave 100px margin for menu
LK.gui.topRight.addChild(projectileCounterTxt);
// Helper to update the UI text
function updateProjectileCounterUI() {
projectileCounterTxt.setText("Next shot: " + coinsToNextProjectile);
}
updateProjectileCounterUI();
// All round/coin/hero/beat grid setup is now handled in startNextRound. No legacy setup should remain.
// All beat grid and coin initialization is now handled in startNextRound. Remove any legacy grid/coin setup elsewhere.
/**
* Resets the game state and starts the next round.
*/
function startNextRound() {
// --- Step A: Clean the Slate ---
// Destroy all old game objects (Coin, Enemy, Key, Princess) from the previous round and clear grid
for (var r = 0; r < gameGrid.playableRows; r++) {
for (var c = 0; c < GameConstants.GRID_COLS; c++) {
var obj = gameGrid.grid[r][c];
if (obj && typeof obj.destroy === "function") {
obj.destroy();
}
gameGrid.grid[r][c] = null;
}
}
// --- Step B: Reset the Rules ---
currentRound++;
collectedCoins = 0;
playerHasKey = false;
// --- Step C: Create a "Pool" of All Available Cells ---
var cellPool = [];
for (var r = 0; r < gameGrid.playableRows; r++) {
for (var c = 0; c < GameConstants.GRID_COLS; c++) {
cellPool.push({
row: r,
col: c
});
}
}
// --- Step D: Shuffle the Pool ---
for (var i = cellPool.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = cellPool[i];
cellPool[i] = cellPool[j];
cellPool[j] = temp;
}
// --- Step E: Place the Special Items ---
// Always (re)initialize the hero via playerService.initialize()
playerService.initialize();
var hero = playerService.getHeroInstance();
// Place the Player
var heroCell = cellPool.shift();
hero.gridRow = heroCell.row;
hero.currentCol = heroCell.col;
hero.x = GameConstants.GRID_LEFT + heroCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
hero.y = GameConstants.GRID_TOP + heroCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
GameConstants.CHAR_ROW = heroCell.row;
// Place the Princess (not adjacent to hero if possible)
var princessCell;
for (var idx = 0; idx < cellPool.length; idx++) {
var candidate = cellPool[idx];
var isAdjacent = Math.abs(candidate.row - heroCell.row) <= 1 && Math.abs(candidate.col - heroCell.col) <= 1;
if (!isAdjacent) {
princessCell = candidate;
cellPool.splice(idx, 1);
break;
}
}
if (!princessCell) {
princessCell = cellPool.shift();
}
var princess = new Princess();
princess.x = GameConstants.GRID_LEFT + princessCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
princess.y = GameConstants.GRID_TOP + princessCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
game.addChild(princess);
gameGrid.grid[princessCell.row][princessCell.col] = princess;
// Place the Key
var keyCell = cellPool.shift();
var key = new Key();
key.x = GameConstants.GRID_LEFT + keyCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
key.y = GameConstants.GRID_TOP + keyCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
game.addChild(key);
gameGrid.grid[keyCell.row][keyCell.col] = key;
// Place the Enemies (currentRound - 1)
var numEnemies = Math.max(0, currentRound - 1);
for (var e = 0; e < numEnemies && cellPool.length > 0; e++) {
var enemyCell = cellPool.shift();
var enemy = new Enemy();
enemy.x = GameConstants.GRID_LEFT + enemyCell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
enemy.y = GameConstants.GRID_TOP + enemyCell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
game.addChild(enemy);
gameGrid.grid[enemyCell.row][enemyCell.col] = enemy;
}
// --- Step F: Fill the Rest with Coins ---
var coinCount = 0;
for (var i = 0; i < cellPool.length; i++) {
var cell = cellPool[i];
var coin = new Coin();
coin.x = GameConstants.GRID_LEFT + cell.col * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
coin.y = GameConstants.GRID_TOP + cell.row * GameConstants.CELL_SIZE + GameConstants.CELL_SIZE / 2;
game.addChild(coin);
gameGrid.grid[cell.row][cell.col] = coin;
coinCount++;
}
// --- Step G: Final Housekeeping ---
// Set totalCoins to the number of remaining cells in cellPool (i.e., the number of coins placed)
totalCoins = cellPool.length;
// Re-initialize the beat grid so it is visible and interactive after round setup
gameGrid.initialize();
// --- Reset all beat grid cells to inactive so no beats remain active from previous level ---
for (var r = 0; r < GameConstants.BEAT_ROWS; r++) {
for (var c = 0; c < GameConstants.GRID_COLS; c++) {
var cell = gameGrid.getBeatCell(r, c);
if (cell && cell.active) {
cell.setActive(false);
gridService.updateCellVisual(cell);
}
}
}
antiCheatService.reset();
// Re-enable beat grid interactivity after round setup
inputService.isPainting = false;
inputService.paintMode = null;
inputService.lastPaintedCell = null;
inputService.dragHero = false;
AudioService.playNewLevel();
}
/**
* Checks if the round is complete and triggers the next round if it is.
* This function will be called whenever a coin is collected.
*/
function checkRoundCompletion() {
// No longer used for level completion
}
// --- Patch coin collection logic to update counter and reset on projectile fire ---
// We'll patch the doStepActions method in SequencerService below
var uiService = new UIService(game);
var gameGrid = new GameGrid(game);
var antiCheatService = new AntiCheatService(null, gameGrid); // Initialize before gridService
var gridService = new GridService(game, gameGrid);
var playerService = new PlayerService(game);
// Do NOT call playerService.initialize() here; hero creation is handled in startNextRound only.
var inputService = new InputService(gridService, playerService);
var sequencerService = new SequencerService(gridService, playerService, uiService);
// 3. Initialize Services in correct order
uiService.initialize();
sequencerService.initialize();
antiCheatService.initialize();
// Set currentRound to 0 so first call to startNextRound makes it 1
currentRound = 0;
startNextRound();
// Remove any legacy or duplicate round/game start logic below this point to prevent double round initialization
// 4. Connect Input Handlers to the Input Service
game.down = function (x, y) {
inputService.handleDown(x, y);
};
game.up = function (x, y) {
inputService.handleUp(x, y);
};
game.move = function (x, y) {
inputService.handleMove(x, y);
};
// 5. Set Main Game Loop to the Sequencer Service
game.update = function () {
sequencerService.update();
};
// 6. Start background music
AudioService.startMusic();
;
;
// End of file. All round/game start logic is now centralized in startNextRound.
cute 2D illustration of a delicious musical note as a collectible item in a casual mobile game. In-Game asset. 2d. High contrast. No shadows
an angry isometric red square bullfrog character for a casual mobile game, facing the camera directly. In-Game asset. 2d. High contrast. No shadows
a delicious looking isometric symphony shaped golden key drawn as a 2D illustration for a cute mobile game. In-Game asset. 2d. High contrast. No shadows
a delicious looking isometric heart icon drawn as a 2D illustration for a cute mobile game. In-Game asset. 2d. High contrast. No shadows. In-Game asset. 2d. High contrast. No shadows
4-panel comic strip, no text, cute cartoon style, bright colors, black outlines. Characters: Penguin Hero (small, determined) + Penguin Princess (elegant, crown) + Village Penguins Central Theme: Music connects hearts, piano mastery wins love. Hero sees Princess's beautiful piano playing, falls in love Panel 1: Hero living normal life in penguin village Panel 2: Hears beautiful piano music from Princess's ice palace Panel 3: Sees Princess playing gracefully, hearts float around Hero Panel 4: Hero determined but nervous, looking at his flippers. In-Game asset. 2d. High contrast. No shadows
4-panel comic strip, no text, cute cartoon style, bright colors, black outlines. Characters: Penguin Hero (small, determined) + Penguin Princess (elegant, crown) + Village Penguins Central Theme: Music connects hearts, piano mastery wins love. Hero doubts himself, thinks he's not good enough Panel 1: Hero tries to approach Princess's palace but stops Panel 2: Sees other talented penguins playing instruments near Princess Panel 3: Hero looks at his flippers sadly, musical notes are wobbly/off-key Panel 4: Hero walks away discouraged, head down. In-Game asset. 2d. High contrast. No shadows
4-panel comic strip, no text, cute cartoon style, bright colors, black outlines. Characters: Penguin Hero (small, determined) + Penguin Princess (elegant, crown) + Village Penguins Central Theme: Music connects hearts, piano mastery wins love. Story: Old wise penguin teaches Hero about music and courage Panel 1: Hero meets old penguin with small piano on ice floe Panel 2: Old penguin demonstrates simple piano scales, notes are warm/golden Panel 3: Hero tries playing, makes mistakes but old penguin encourages Panel 4: Hero practices with determination, musical notes getting brighter. In-Game asset. 2d. High contrast. No shadows
4-panel comic strip, no text, cute cartoon style, bright colors, black outlines. Characters: Penguin Hero (small, determined) + Penguin Princess (elegant, crown) + Village Penguins Central Theme: Music connects hearts, piano mastery wins love. Story: Hero commits to learning piano seriously Panel 1: Hero finds small piano and sets up practice space Panel 2: Hero practicing at sunrise, warm golden morning light Panel 3: Musical notes gradually improve from wobbly to steady Panel 4: Hero still practicing under moonlight, showing dedication through full day In-Game asset. 2d. High contrast. No shadows
4-panel comic strip, no text, cute cartoon style, bright colors, black outlines. Characters: Penguin Hero (small, determined) + Village Penguins Central Theme: Music connects hearts, piano mastery wins love. Story: Hero faces challenges and obstacles in his musical journey Panel 1: Hero struggles with difficult piano piece, frustrated Panel 2: Other penguins mock his practicing, musical notes look harsh/jagged Panel 3: Hero's flippers are sore, he looks exhausted Panel 4: But Hero persists, practicing by moonlight, determined expression In-Game asset. 2d. High contrast. No shadows
4-panel comic strip, no text, cute cartoon style, bright colors, black outlines. Characters: Penguin Hero (small, determined) + Penguin Princess (elegant, crown) + Village Penguins Central Theme: Music connects hearts, piano mastery wins love. Story: Hero's biggest challenge - public performance disaster Panel 1: Hero attempts to play for Princess at village gathering Panel 2: Makes terrible mistakes, wrong red notes everywhere, crowd looks shocked Panel 3: Princess looks disappointed, Hero devastated and embarrassed Panel 4: Hero runs away, hiding behind ice block, feeling defeated In-Game asset. 2d. High contrast. No shadows
4-panel comic strip, no text, cute cartoon style, bright colors, black outlines. Characters: Penguin Hero (small, determined) + Penguin Princess (elegant, crown) + Village Penguins Central Theme: Music connects hearts, piano mastery wins love. Story: Hero finds his true musical voice and inner strength Panel 1: Hero alone with piano, plays from his heart instead of trying to impress Panel 2: Beautiful, unique music flows from him, notes shimmer with emotion Panel 3: Princess secretly listens from distance, moved by his genuine music Panel 4: Hero realizes music should come from the heart, not ego. In-Game asset. 2d. High contrast. No shadows
4-panel comic strip, no text, cute cartoon style, bright colors, black outlines. Characters: Penguin Hero (small, determined) + Penguin Princess (elegant, crown) + Village Penguins Central Theme: Music connects hearts, piano mastery wins love. Story: Hero's grand performance wins Princess's heart and village's admiration Panel 1: Hero plays grand piano on large ice stage, whole village watching Panel 2: Beautiful music fills the air, all penguins are enchanted, notes sparkle Panel 3: Princess approaches Hero, heart symbols floating between them Panel 4: Hero and Princess together at piano, playing duet, village celebrates with hearts/music notes everywhere. In-Game asset. 2d. High contrast. No shadows
A simple top-down 2D illustration of a calm arctic lake. The scene shows the edge of the water, with a soft, snowy shoreline framing the top and one side of the image. The lake itself is a calm, light blue, with subtle light reflections on the surface to suggest water. The art style is soft and clean, like a children's book illustration.. In-Game asset. 2d. High contrast. No shadows
cold water splash 2d illustration. top-view perspective. In-Game asset. 2d. High contrast. No shadows