/**** * Plugins ****/ var tween = LK.import("@upit/tween.v1"); var storage = LK.import("@upit/storage.v1", { level: 1 }); /**** * Classes ****/ // Candy class var Candy = Container.expand(function () { var self = Container.call(this); // Properties self.type = null; // 0-5 for normal, 'stripedH', 'stripedV', 'bomb' self.row = 0; self.col = 0; self.special = null; // null, 'stripedH', 'stripedV', 'bomb' self.selected = false; // Attach asset self.setType = function (type, special) { // Remove previous asset if any if (self.candyAsset) { self.removeChild(self.candyAsset); } self.type = type; self.special = special || null; var assetId; if (special === 'stripedH') assetId = 'candyStripedH';else if (special === 'stripedV') assetId = 'candyStripedV';else if (special === 'bomb') assetId = 'candyBomb';else { assetId = ['candyRed', 'candyGreen', 'candyBlue', 'candyYellow', 'candyPurple', 'candyOrange'][type]; } self.candyAsset = self.attachAsset(assetId, { anchorX: 0.5, anchorY: 0.5 }); // Tint for special candies if (special === 'stripedH') self.candyAsset.tint = 0xccccff; if (special === 'stripedV') self.candyAsset.tint = 0xffcccc; if (special === 'bomb') self.candyAsset.tint = 0x222222; }; // Highlight self.setSelected = function (sel) { self.selected = sel; if (self.candyAsset) { self.candyAsset.alpha = sel ? 0.7 : 1; if (sel) { tween(self.candyAsset, { scaleX: 1.15, scaleY: 1.15 }, { duration: 120, easing: tween.easeOut }); } else { tween(self.candyAsset, { scaleX: 1, scaleY: 1 }, { duration: 120, easing: tween.easeOut }); } } }; // Animate removal self.animateRemove = function (cb) { if (self.candyAsset) { tween(self.candyAsset, { scaleX: 0, scaleY: 0, alpha: 0 }, { duration: 180, easing: tween.cubicIn, onFinish: cb }); } else if (cb) cb(); }; // Animate drop self.animateDrop = function (targetY, cb) { tween(self, { y: targetY }, { duration: 180, easing: tween.cubicOut, onFinish: cb }); }; // Animate swap self.animateSwap = function (targetX, targetY, cb) { tween(self, { x: targetX, y: targetY }, { duration: 120, easing: tween.cubicInOut, onFinish: cb }); }; return self; }); /**** * Initialize Game ****/ var game = new LK.Game({ backgroundColor: 0x181830 }); /**** * Game Code ****/ // Music // Sound effects // Board background // Special candies // Candies: 6 types, each a colored ellipse // Board config var BOARD_SIZE = 9; var CELL_SIZE = 180; var BOARD_OFFSET_X = (2048 - BOARD_SIZE * CELL_SIZE) / 2; var BOARD_OFFSET_Y = 350; var CANDY_TYPES = 6; // Game state var board = []; // 2D array [row][col] of Candy var selectedCandy = null; var isSwapping = false; var isAnimating = false; var movesLeft = 20; var score = 0; var level = storage.level || 1; var targetScore = 2000 + (level - 1) * 500; // UI var scoreTxt = new Text2('Score: 0', { size: 90, fill: '#fff' }); scoreTxt.anchor.set(0.5, 0); LK.gui.top.addChild(scoreTxt); var movesTxt = new Text2('Moves: 20', { size: 70, fill: '#fff' }); movesTxt.anchor.set(0.5, 0); LK.gui.top.addChild(movesTxt); movesTxt.y = 110; var levelTxt = new Text2('Level 1', { size: 60, fill: '#fff' }); levelTxt.anchor.set(0.5, 0); // Place level text below the board levelTxt.x = 2048 / 2; levelTxt.y = BOARD_OFFSET_Y + BOARD_SIZE * CELL_SIZE + 40; game.addChild(levelTxt); // Board background var boardBg = LK.getAsset('boardBg', { anchorX: 0.5, anchorY: 0.5, x: 2048 / 2, y: BOARD_OFFSET_Y + BOARD_SIZE * CELL_SIZE / 2 }); game.addChild(boardBg); // Helper: get board position function getCandyPos(row, col) { return { x: BOARD_OFFSET_X + col * CELL_SIZE + CELL_SIZE / 2, y: BOARD_OFFSET_Y + row * CELL_SIZE + CELL_SIZE / 2 }; } // Helper: random candy type function randomCandyType() { return Math.floor(Math.random() * CANDY_TYPES); } // Helper: check if two candies are adjacent function areAdjacent(c1, c2) { return Math.abs(c1.row - c2.row) + Math.abs(c1.col - c2.col) === 1; } // Helper: swap candies in board array function swapCandies(c1, c2) { var temp = board[c1.row][c1.col]; board[c1.row][c1.col] = board[c2.row][c2.col]; board[c2.row][c2.col] = temp; // Update row/col var trow = c1.row, tcol = c1.col; c1.row = c2.row; c1.col = c2.col; c2.row = trow; c2.col = tcol; } // Helper: find all matches (returns array of arrays of candies) function findMatches() { var matches = []; // Horizontal for (var r = 0; r < BOARD_SIZE; r++) { var run = [board[r][0]]; for (var c = 1; c < BOARD_SIZE; c++) { var prev = board[r][c - 1], curr = board[r][c]; // Only match if both are normal candies and same type if (curr && prev && typeof curr.type === "number" && typeof prev.type === "number" && curr.type === prev.type) { run.push(curr); } else { if (run.length >= 3) matches.push(run.slice()); run = [curr]; } } if (run.length >= 3) matches.push(run.slice()); } // Vertical for (var c = 0; c < BOARD_SIZE; c++) { var run = [board[0][c]]; for (var r = 1; r < BOARD_SIZE; r++) { var prev = board[r - 1][c], curr = board[r][c]; // Only match if both are normal candies and same type if (curr && prev && typeof curr.type === "number" && typeof prev.type === "number" && curr.type === prev.type) { run.push(curr); } else { if (run.length >= 3) matches.push(run.slice()); run = [curr]; } } if (run.length >= 3) matches.push(run.slice()); } return matches; } // Helper: check if board has any possible moves function hasPossibleMoves() { // Try swapping every adjacent pair, see if it creates a match for (var r = 0; r < BOARD_SIZE; r++) { for (var c = 0; c < BOARD_SIZE; c++) { var curr = board[r][c]; // Right if (c < BOARD_SIZE - 1) { swapCandies(curr, board[r][c + 1]); if (findMatches().length > 0) { swapCandies(curr, board[r][c + 1]); return true; } swapCandies(curr, board[r][c + 1]); } // Down if (r < BOARD_SIZE - 1) { swapCandies(curr, board[r + 1][c]); if (findMatches().length > 0) { swapCandies(curr, board[r + 1][c]); return true; } swapCandies(curr, board[r + 1][c]); } } } return false; } // Helper: refill board (drop candies, fill new) function refillBoard(cb) { var dropping = 0; for (var c = 0; c < BOARD_SIZE; c++) { // Collect all candies in this column, compacting them to the bottom var colCandies = []; for (var r = BOARD_SIZE - 1; r >= 0; r--) { if (board[r][c]) { colCandies.push(board[r][c]); board[r][c] = null; } } // Place candies at the bottom, filling from bottom up var r = BOARD_SIZE - 1; for (var i = 0; i < colCandies.length; i++, r--) { var candy = colCandies[i]; board[r][c] = candy; var oldRow = candy.row; candy.row = r; candy.col = c; var pos = getCandyPos(candy.row, candy.col); if (oldRow !== candy.row) { dropping++; candy.animateDrop(pos.y, function () { dropping--; if (dropping === 0 && cb) cb(); }); } else { // No animation needed if not moved candy.y = pos.y; } } // Fill new candies at the top for (; r >= 0; r--) { // Defensive: Only place a new candy if the cell is empty (should always be, but double check) if (!board[r][c]) { var newCandy = new Candy(); var type = randomCandyType(); newCandy.setType(type); newCandy.row = r; newCandy.col = c; var pos = getCandyPos(r, c); newCandy.x = pos.x; // Place new candy well above the board, so it drops down and never overlaps newCandy.y = pos.y - CELL_SIZE * (BOARD_SIZE + 2); // Always above the board, so no overlap board[r][c] = newCandy; game.addChild(newCandy); dropping++; newCandy.animateDrop(pos.y, function () { dropping--; if (dropping === 0 && cb) cb(); }); } } // Defensive: After refill, ensure no empty spaces remain in this column and no overlaps for (var rr = 0; rr < BOARD_SIZE; rr++) { // Remove any extra candies in this cell (should never happen, but just in case) if (Array.isArray(board[rr][c])) { // If for some reason multiple candies are in the same cell, keep only the last one while (board[rr][c].length > 1) { var extra = board[rr][c].shift(); if (extra && extra.destroy) extra.destroy(); } board[rr][c] = board[rr][c][0]; } // Check for overlapping candies (should never happen, but just in case) for (var checkR = 0; checkR < BOARD_SIZE; checkR++) { if (checkR !== rr && board[rr][c] && board[checkR][c] && board[rr][c] === board[checkR][c]) { // Overlap detected, destroy one of them if (board[rr][c].destroy) board[rr][c].destroy(); board[rr][c] = null; } } if (!board[rr][c]) { // If any empty, fill with a new candy var fillCandy = new Candy(); var fillType = randomCandyType(); fillCandy.setType(fillType); fillCandy.row = rr; fillCandy.col = c; var fillPos = getCandyPos(rr, c); fillCandy.x = fillPos.x; fillCandy.y = fillPos.y - CELL_SIZE * (BOARD_SIZE + 2); board[rr][c] = fillCandy; game.addChild(fillCandy); dropping++; fillCandy.animateDrop(fillPos.y, function () { dropping--; if (dropping === 0 && cb) cb(); }); } } } if (dropping === 0 && cb) cb(); } // Helper: remove matched candies function removeMatches(matches, cb) { var removing = 0; for (var i = 0; i < matches.length; i++) { var group = matches[i]; for (var j = 0; j < group.length; j++) { var candy = group[j]; if (board[candy.row][candy.col]) { board[candy.row][candy.col] = null; removing++; // Visual effect for popping var popEffect = new Text2('💥', { size: 110, fill: '#fff' }); popEffect.anchor.set(0.5, 0.5); var pos = getCandyPos(candy.row, candy.col); popEffect.x = pos.x; popEffect.y = pos.y; popEffect.alpha = 1; game.addChild(popEffect); tween(popEffect, { scaleX: 1.7, scaleY: 1.7, alpha: 0 }, { duration: 350, easing: tween.cubicOut, onFinish: function onFinish() { popEffect.destroy(); } }); candy.animateRemove(function () { candy.destroy(); removing--; if (removing === 0 && cb) cb(); }); } } } if (removing === 0 && cb) cb(); } // Helper: clear selection function clearSelection() { if (selectedCandy) { selectedCandy.setSelected(false); selectedCandy = null; } } // Helper: update UI function updateUI() { scoreTxt.setText('Score: ' + score); movesTxt.setText('Moves: ' + movesLeft); levelTxt.setText('Level ' + level); } // Helper: check win/lose function checkEnd() { if (score >= targetScore) { LK.getSound('win').play(); storage.level = level + 1; LK.showYouWin(); return true; } if (movesLeft <= 0) { LK.getSound('fail').play(); LK.showGameOver(); return true; } return false; } // Helper: shuffle board function shuffleBoard() { // Remove all candies for (var r = 0; r < BOARD_SIZE; r++) { for (var c = 0; c < BOARD_SIZE; c++) { if (board[r][c]) { board[r][c].destroy(); board[r][c] = null; } } } // Refill for (var r = 0; r < BOARD_SIZE; r++) { for (var c = 0; c < BOARD_SIZE; c++) { // Defensive: Only place a new candy if the cell is empty (should always be, but double check) if (!board[r][c]) { var candy = new Candy(); var type = randomCandyType(); candy.setType(type); candy.row = r; candy.col = c; var pos = getCandyPos(r, c); candy.x = pos.x; candy.y = pos.y; board[r][c] = candy; game.addChild(candy); } } } } // Initialize board function initBoard() { // Remove old candies for (var r = 0; r < BOARD_SIZE; r++) { for (var c = 0; c < BOARD_SIZE; c++) { if (board[r] && board[r][c]) { board[r][c].destroy(); } } } board = []; for (var r = 0; r < BOARD_SIZE; r++) { board[r] = []; for (var c = 0; c < BOARD_SIZE; c++) { // Defensive: Only place a new candy if the cell is empty (should always be, but double check) if (!board[r][c]) { var candy = new Candy(); var type = randomCandyType(); candy.setType(type); candy.row = r; candy.col = c; var pos = getCandyPos(r, c); candy.x = pos.x; candy.y = pos.y; board[r][c] = candy; game.addChild(candy); } } } // Defensive: After init, ensure no empty spaces or overlaps for (var r = 0; r < BOARD_SIZE; r++) { for (var c = 0; c < BOARD_SIZE; c++) { // Remove any extra candies in this cell (should never happen, but just in case) if (Array.isArray(board[r][c])) { while (board[r][c].length > 1) { var extra = board[r][c].shift(); if (extra && extra.destroy) extra.destroy(); } board[r][c] = board[r][c][0]; } // Check for overlapping candies (should never happen, but just in case) for (var checkR = 0; checkR < BOARD_SIZE; checkR++) { for (var checkC = 0; checkC < BOARD_SIZE; checkC++) { if ((checkR !== r || checkC !== c) && board[r][c] && board[checkR][checkC] && board[r][c] === board[checkR][checkC]) { // Overlap detected, destroy one of them if (board[r][c].destroy) board[r][c].destroy(); board[r][c] = null; } } } if (!board[r][c]) { // If any empty, fill with a new candy var fillCandy = new Candy(); var fillType = randomCandyType(); fillCandy.setType(fillType); fillCandy.row = r; fillCandy.col = c; var fillPos = getCandyPos(r, c); fillCandy.x = fillPos.x; fillCandy.y = fillPos.y; board[r][c] = fillCandy; game.addChild(fillCandy); } } } // Remove initial matches var initialMatches = findMatches(); while (initialMatches.length > 0) { removeMatches(initialMatches, function () {}); refillBoard(function () {}); initialMatches = findMatches(); } } // Start new level/game function startGame() { level = storage.level || 1; targetScore = 2000 + (level - 1) * 500; movesLeft = 20 + Math.floor(level / 2); score = 0; updateUI(); clearSelection(); initBoard(); LK.playMusic('bgmusic', { loop: true, fade: { start: 0, end: 0.7, duration: 800 } }); } // Handle candy selection/swapping function handleCandyDown(x, y, obj) { if (isAnimating || isSwapping) return; // Find which candy was pressed // Defensive: Use event coordinates directly, as obj.parent or obj.position may be undefined var row = Math.floor((y - BOARD_OFFSET_Y) / CELL_SIZE); var col = Math.floor((x - BOARD_OFFSET_X) / CELL_SIZE); if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return; var candy = board[row][col]; if (!candy) return; if (!selectedCandy) { selectedCandy = candy; candy.setSelected(true); } else if (candy === selectedCandy) { clearSelection(); } else if (areAdjacent(selectedCandy, candy)) { // Swap isSwapping = true; selectedCandy.setSelected(false); var c1 = selectedCandy, c2 = candy; var pos1 = getCandyPos(c1.row, c1.col); var pos2 = getCandyPos(c2.row, c2.col); c1.animateSwap(pos2.x, pos2.y, function () {}); c2.animateSwap(pos1.x, pos1.y, function () { swapCandies(c1, c2); LK.getSound('swap').play(); // Check for matches var matches = findMatches(); if (matches.length > 0) { movesLeft--; updateUI(); processMatches(matches); } else { // No matches, swap back and decrement moves movesLeft--; updateUI(); c1.animateSwap(pos1.x, pos1.y, function () {}); c2.animateSwap(pos2.x, pos2.y, function () { swapCandies(c1, c2); isSwapping = false; }); } clearSelection(); }); } else { selectedCandy.setSelected(false); selectedCandy = candy; candy.setSelected(true); } } // Process matches and cascades // Combo system variables (global scope) if (typeof comboCount === "undefined") { var comboCount = 0; var comboTimer = null; } // Visual effect for combo (global helper) function showComboEffect(comboNum) { if (comboNum <= 1) return; var comboText = new Text2('Combo x' + comboNum, { size: 110, fill: '#fffa00', font: "'GillSans-Bold',Impact,'Arial Black',Tahoma" }); comboText.anchor.set(1, 0); // right-top comboText.x = 2048 - 80; comboText.y = 350; comboText.alpha = 1; LK.gui.topRight.addChild(comboText); tween(comboText, { y: comboText.y - 80, alpha: 0 }, { duration: 700, easing: tween.cubicOut, onFinish: function onFinish() { comboText.destroy(); } }); } function processMatches(matches) { isAnimating = true; LK.getSound('match').play(); // Combo logic if (typeof comboCount === "undefined") comboCount = 0; comboCount++; showComboEffect(comboCount); // Score var points = 0; for (var i = 0; i < matches.length; i++) { points += matches[i].length * 60 * comboCount; } score += points; updateUI(); // Remove matches, refill, and check for cascades removeMatches(matches, function () { refillBoard(function () { // Only allow further matches if they are valid (no random pops) var newMatches = findMatches(); // Only process if the new matches are valid (length >= 3) var validMatches = []; for (var i = 0; i < newMatches.length; i++) { if (newMatches[i].length >= 3) validMatches.push(newMatches[i]); } // Defensive: Only process matches if they are a result of a cascade (not random) if (validMatches.length > 0) { LK.getSound('cascade').play(); // Reset combo timer if running if (comboTimer) LK.clearTimeout(comboTimer); processMatches(validMatches); } else { // Combo chain ends, reset after a short delay if (comboTimer) LK.clearTimeout(comboTimer); comboTimer = LK.setTimeout(function () { comboCount = 0; }, 900); isAnimating = false; isSwapping = false; if (!hasPossibleMoves()) { shuffleBoard(); } checkEnd(); } }); }); } // Touch/mouse events game.down = function (x, y, obj) { if (isAnimating || isSwapping) return; // Only allow selection inside board area if (x < BOARD_OFFSET_X || x > BOARD_OFFSET_X + BOARD_SIZE * CELL_SIZE || y < BOARD_OFFSET_Y || y > BOARD_OFFSET_Y + BOARD_SIZE * CELL_SIZE) return; handleCandyDown(x, y, obj); }; game.move = function (x, y, obj) { // No drag/swipe for MVP }; game.up = function (x, y, obj) { // No drag/swipe for MVP }; // Main update loop (not used for logic in MVP) game.update = function () { // No per-frame logic needed for MVP }; // Start game startGame();
/****
* Plugins
****/
var tween = LK.import("@upit/tween.v1");
var storage = LK.import("@upit/storage.v1", {
level: 1
});
/****
* Classes
****/
// Candy class
var Candy = Container.expand(function () {
var self = Container.call(this);
// Properties
self.type = null; // 0-5 for normal, 'stripedH', 'stripedV', 'bomb'
self.row = 0;
self.col = 0;
self.special = null; // null, 'stripedH', 'stripedV', 'bomb'
self.selected = false;
// Attach asset
self.setType = function (type, special) {
// Remove previous asset if any
if (self.candyAsset) {
self.removeChild(self.candyAsset);
}
self.type = type;
self.special = special || null;
var assetId;
if (special === 'stripedH') assetId = 'candyStripedH';else if (special === 'stripedV') assetId = 'candyStripedV';else if (special === 'bomb') assetId = 'candyBomb';else {
assetId = ['candyRed', 'candyGreen', 'candyBlue', 'candyYellow', 'candyPurple', 'candyOrange'][type];
}
self.candyAsset = self.attachAsset(assetId, {
anchorX: 0.5,
anchorY: 0.5
});
// Tint for special candies
if (special === 'stripedH') self.candyAsset.tint = 0xccccff;
if (special === 'stripedV') self.candyAsset.tint = 0xffcccc;
if (special === 'bomb') self.candyAsset.tint = 0x222222;
};
// Highlight
self.setSelected = function (sel) {
self.selected = sel;
if (self.candyAsset) {
self.candyAsset.alpha = sel ? 0.7 : 1;
if (sel) {
tween(self.candyAsset, {
scaleX: 1.15,
scaleY: 1.15
}, {
duration: 120,
easing: tween.easeOut
});
} else {
tween(self.candyAsset, {
scaleX: 1,
scaleY: 1
}, {
duration: 120,
easing: tween.easeOut
});
}
}
};
// Animate removal
self.animateRemove = function (cb) {
if (self.candyAsset) {
tween(self.candyAsset, {
scaleX: 0,
scaleY: 0,
alpha: 0
}, {
duration: 180,
easing: tween.cubicIn,
onFinish: cb
});
} else if (cb) cb();
};
// Animate drop
self.animateDrop = function (targetY, cb) {
tween(self, {
y: targetY
}, {
duration: 180,
easing: tween.cubicOut,
onFinish: cb
});
};
// Animate swap
self.animateSwap = function (targetX, targetY, cb) {
tween(self, {
x: targetX,
y: targetY
}, {
duration: 120,
easing: tween.cubicInOut,
onFinish: cb
});
};
return self;
});
/****
* Initialize Game
****/
var game = new LK.Game({
backgroundColor: 0x181830
});
/****
* Game Code
****/
// Music
// Sound effects
// Board background
// Special candies
// Candies: 6 types, each a colored ellipse
// Board config
var BOARD_SIZE = 9;
var CELL_SIZE = 180;
var BOARD_OFFSET_X = (2048 - BOARD_SIZE * CELL_SIZE) / 2;
var BOARD_OFFSET_Y = 350;
var CANDY_TYPES = 6;
// Game state
var board = []; // 2D array [row][col] of Candy
var selectedCandy = null;
var isSwapping = false;
var isAnimating = false;
var movesLeft = 20;
var score = 0;
var level = storage.level || 1;
var targetScore = 2000 + (level - 1) * 500;
// UI
var scoreTxt = new Text2('Score: 0', {
size: 90,
fill: '#fff'
});
scoreTxt.anchor.set(0.5, 0);
LK.gui.top.addChild(scoreTxt);
var movesTxt = new Text2('Moves: 20', {
size: 70,
fill: '#fff'
});
movesTxt.anchor.set(0.5, 0);
LK.gui.top.addChild(movesTxt);
movesTxt.y = 110;
var levelTxt = new Text2('Level 1', {
size: 60,
fill: '#fff'
});
levelTxt.anchor.set(0.5, 0);
// Place level text below the board
levelTxt.x = 2048 / 2;
levelTxt.y = BOARD_OFFSET_Y + BOARD_SIZE * CELL_SIZE + 40;
game.addChild(levelTxt);
// Board background
var boardBg = LK.getAsset('boardBg', {
anchorX: 0.5,
anchorY: 0.5,
x: 2048 / 2,
y: BOARD_OFFSET_Y + BOARD_SIZE * CELL_SIZE / 2
});
game.addChild(boardBg);
// Helper: get board position
function getCandyPos(row, col) {
return {
x: BOARD_OFFSET_X + col * CELL_SIZE + CELL_SIZE / 2,
y: BOARD_OFFSET_Y + row * CELL_SIZE + CELL_SIZE / 2
};
}
// Helper: random candy type
function randomCandyType() {
return Math.floor(Math.random() * CANDY_TYPES);
}
// Helper: check if two candies are adjacent
function areAdjacent(c1, c2) {
return Math.abs(c1.row - c2.row) + Math.abs(c1.col - c2.col) === 1;
}
// Helper: swap candies in board array
function swapCandies(c1, c2) {
var temp = board[c1.row][c1.col];
board[c1.row][c1.col] = board[c2.row][c2.col];
board[c2.row][c2.col] = temp;
// Update row/col
var trow = c1.row,
tcol = c1.col;
c1.row = c2.row;
c1.col = c2.col;
c2.row = trow;
c2.col = tcol;
}
// Helper: find all matches (returns array of arrays of candies)
function findMatches() {
var matches = [];
// Horizontal
for (var r = 0; r < BOARD_SIZE; r++) {
var run = [board[r][0]];
for (var c = 1; c < BOARD_SIZE; c++) {
var prev = board[r][c - 1],
curr = board[r][c];
// Only match if both are normal candies and same type
if (curr && prev && typeof curr.type === "number" && typeof prev.type === "number" && curr.type === prev.type) {
run.push(curr);
} else {
if (run.length >= 3) matches.push(run.slice());
run = [curr];
}
}
if (run.length >= 3) matches.push(run.slice());
}
// Vertical
for (var c = 0; c < BOARD_SIZE; c++) {
var run = [board[0][c]];
for (var r = 1; r < BOARD_SIZE; r++) {
var prev = board[r - 1][c],
curr = board[r][c];
// Only match if both are normal candies and same type
if (curr && prev && typeof curr.type === "number" && typeof prev.type === "number" && curr.type === prev.type) {
run.push(curr);
} else {
if (run.length >= 3) matches.push(run.slice());
run = [curr];
}
}
if (run.length >= 3) matches.push(run.slice());
}
return matches;
}
// Helper: check if board has any possible moves
function hasPossibleMoves() {
// Try swapping every adjacent pair, see if it creates a match
for (var r = 0; r < BOARD_SIZE; r++) {
for (var c = 0; c < BOARD_SIZE; c++) {
var curr = board[r][c];
// Right
if (c < BOARD_SIZE - 1) {
swapCandies(curr, board[r][c + 1]);
if (findMatches().length > 0) {
swapCandies(curr, board[r][c + 1]);
return true;
}
swapCandies(curr, board[r][c + 1]);
}
// Down
if (r < BOARD_SIZE - 1) {
swapCandies(curr, board[r + 1][c]);
if (findMatches().length > 0) {
swapCandies(curr, board[r + 1][c]);
return true;
}
swapCandies(curr, board[r + 1][c]);
}
}
}
return false;
}
// Helper: refill board (drop candies, fill new)
function refillBoard(cb) {
var dropping = 0;
for (var c = 0; c < BOARD_SIZE; c++) {
// Collect all candies in this column, compacting them to the bottom
var colCandies = [];
for (var r = BOARD_SIZE - 1; r >= 0; r--) {
if (board[r][c]) {
colCandies.push(board[r][c]);
board[r][c] = null;
}
}
// Place candies at the bottom, filling from bottom up
var r = BOARD_SIZE - 1;
for (var i = 0; i < colCandies.length; i++, r--) {
var candy = colCandies[i];
board[r][c] = candy;
var oldRow = candy.row;
candy.row = r;
candy.col = c;
var pos = getCandyPos(candy.row, candy.col);
if (oldRow !== candy.row) {
dropping++;
candy.animateDrop(pos.y, function () {
dropping--;
if (dropping === 0 && cb) cb();
});
} else {
// No animation needed if not moved
candy.y = pos.y;
}
}
// Fill new candies at the top
for (; r >= 0; r--) {
// Defensive: Only place a new candy if the cell is empty (should always be, but double check)
if (!board[r][c]) {
var newCandy = new Candy();
var type = randomCandyType();
newCandy.setType(type);
newCandy.row = r;
newCandy.col = c;
var pos = getCandyPos(r, c);
newCandy.x = pos.x;
// Place new candy well above the board, so it drops down and never overlaps
newCandy.y = pos.y - CELL_SIZE * (BOARD_SIZE + 2); // Always above the board, so no overlap
board[r][c] = newCandy;
game.addChild(newCandy);
dropping++;
newCandy.animateDrop(pos.y, function () {
dropping--;
if (dropping === 0 && cb) cb();
});
}
}
// Defensive: After refill, ensure no empty spaces remain in this column and no overlaps
for (var rr = 0; rr < BOARD_SIZE; rr++) {
// Remove any extra candies in this cell (should never happen, but just in case)
if (Array.isArray(board[rr][c])) {
// If for some reason multiple candies are in the same cell, keep only the last one
while (board[rr][c].length > 1) {
var extra = board[rr][c].shift();
if (extra && extra.destroy) extra.destroy();
}
board[rr][c] = board[rr][c][0];
}
// Check for overlapping candies (should never happen, but just in case)
for (var checkR = 0; checkR < BOARD_SIZE; checkR++) {
if (checkR !== rr && board[rr][c] && board[checkR][c] && board[rr][c] === board[checkR][c]) {
// Overlap detected, destroy one of them
if (board[rr][c].destroy) board[rr][c].destroy();
board[rr][c] = null;
}
}
if (!board[rr][c]) {
// If any empty, fill with a new candy
var fillCandy = new Candy();
var fillType = randomCandyType();
fillCandy.setType(fillType);
fillCandy.row = rr;
fillCandy.col = c;
var fillPos = getCandyPos(rr, c);
fillCandy.x = fillPos.x;
fillCandy.y = fillPos.y - CELL_SIZE * (BOARD_SIZE + 2);
board[rr][c] = fillCandy;
game.addChild(fillCandy);
dropping++;
fillCandy.animateDrop(fillPos.y, function () {
dropping--;
if (dropping === 0 && cb) cb();
});
}
}
}
if (dropping === 0 && cb) cb();
}
// Helper: remove matched candies
function removeMatches(matches, cb) {
var removing = 0;
for (var i = 0; i < matches.length; i++) {
var group = matches[i];
for (var j = 0; j < group.length; j++) {
var candy = group[j];
if (board[candy.row][candy.col]) {
board[candy.row][candy.col] = null;
removing++;
// Visual effect for popping
var popEffect = new Text2('💥', {
size: 110,
fill: '#fff'
});
popEffect.anchor.set(0.5, 0.5);
var pos = getCandyPos(candy.row, candy.col);
popEffect.x = pos.x;
popEffect.y = pos.y;
popEffect.alpha = 1;
game.addChild(popEffect);
tween(popEffect, {
scaleX: 1.7,
scaleY: 1.7,
alpha: 0
}, {
duration: 350,
easing: tween.cubicOut,
onFinish: function onFinish() {
popEffect.destroy();
}
});
candy.animateRemove(function () {
candy.destroy();
removing--;
if (removing === 0 && cb) cb();
});
}
}
}
if (removing === 0 && cb) cb();
}
// Helper: clear selection
function clearSelection() {
if (selectedCandy) {
selectedCandy.setSelected(false);
selectedCandy = null;
}
}
// Helper: update UI
function updateUI() {
scoreTxt.setText('Score: ' + score);
movesTxt.setText('Moves: ' + movesLeft);
levelTxt.setText('Level ' + level);
}
// Helper: check win/lose
function checkEnd() {
if (score >= targetScore) {
LK.getSound('win').play();
storage.level = level + 1;
LK.showYouWin();
return true;
}
if (movesLeft <= 0) {
LK.getSound('fail').play();
LK.showGameOver();
return true;
}
return false;
}
// Helper: shuffle board
function shuffleBoard() {
// Remove all candies
for (var r = 0; r < BOARD_SIZE; r++) {
for (var c = 0; c < BOARD_SIZE; c++) {
if (board[r][c]) {
board[r][c].destroy();
board[r][c] = null;
}
}
}
// Refill
for (var r = 0; r < BOARD_SIZE; r++) {
for (var c = 0; c < BOARD_SIZE; c++) {
// Defensive: Only place a new candy if the cell is empty (should always be, but double check)
if (!board[r][c]) {
var candy = new Candy();
var type = randomCandyType();
candy.setType(type);
candy.row = r;
candy.col = c;
var pos = getCandyPos(r, c);
candy.x = pos.x;
candy.y = pos.y;
board[r][c] = candy;
game.addChild(candy);
}
}
}
}
// Initialize board
function initBoard() {
// Remove old candies
for (var r = 0; r < BOARD_SIZE; r++) {
for (var c = 0; c < BOARD_SIZE; c++) {
if (board[r] && board[r][c]) {
board[r][c].destroy();
}
}
}
board = [];
for (var r = 0; r < BOARD_SIZE; r++) {
board[r] = [];
for (var c = 0; c < BOARD_SIZE; c++) {
// Defensive: Only place a new candy if the cell is empty (should always be, but double check)
if (!board[r][c]) {
var candy = new Candy();
var type = randomCandyType();
candy.setType(type);
candy.row = r;
candy.col = c;
var pos = getCandyPos(r, c);
candy.x = pos.x;
candy.y = pos.y;
board[r][c] = candy;
game.addChild(candy);
}
}
}
// Defensive: After init, ensure no empty spaces or overlaps
for (var r = 0; r < BOARD_SIZE; r++) {
for (var c = 0; c < BOARD_SIZE; c++) {
// Remove any extra candies in this cell (should never happen, but just in case)
if (Array.isArray(board[r][c])) {
while (board[r][c].length > 1) {
var extra = board[r][c].shift();
if (extra && extra.destroy) extra.destroy();
}
board[r][c] = board[r][c][0];
}
// Check for overlapping candies (should never happen, but just in case)
for (var checkR = 0; checkR < BOARD_SIZE; checkR++) {
for (var checkC = 0; checkC < BOARD_SIZE; checkC++) {
if ((checkR !== r || checkC !== c) && board[r][c] && board[checkR][checkC] && board[r][c] === board[checkR][checkC]) {
// Overlap detected, destroy one of them
if (board[r][c].destroy) board[r][c].destroy();
board[r][c] = null;
}
}
}
if (!board[r][c]) {
// If any empty, fill with a new candy
var fillCandy = new Candy();
var fillType = randomCandyType();
fillCandy.setType(fillType);
fillCandy.row = r;
fillCandy.col = c;
var fillPos = getCandyPos(r, c);
fillCandy.x = fillPos.x;
fillCandy.y = fillPos.y;
board[r][c] = fillCandy;
game.addChild(fillCandy);
}
}
}
// Remove initial matches
var initialMatches = findMatches();
while (initialMatches.length > 0) {
removeMatches(initialMatches, function () {});
refillBoard(function () {});
initialMatches = findMatches();
}
}
// Start new level/game
function startGame() {
level = storage.level || 1;
targetScore = 2000 + (level - 1) * 500;
movesLeft = 20 + Math.floor(level / 2);
score = 0;
updateUI();
clearSelection();
initBoard();
LK.playMusic('bgmusic', {
loop: true,
fade: {
start: 0,
end: 0.7,
duration: 800
}
});
}
// Handle candy selection/swapping
function handleCandyDown(x, y, obj) {
if (isAnimating || isSwapping) return;
// Find which candy was pressed
// Defensive: Use event coordinates directly, as obj.parent or obj.position may be undefined
var row = Math.floor((y - BOARD_OFFSET_Y) / CELL_SIZE);
var col = Math.floor((x - BOARD_OFFSET_X) / CELL_SIZE);
if (row < 0 || row >= BOARD_SIZE || col < 0 || col >= BOARD_SIZE) return;
var candy = board[row][col];
if (!candy) return;
if (!selectedCandy) {
selectedCandy = candy;
candy.setSelected(true);
} else if (candy === selectedCandy) {
clearSelection();
} else if (areAdjacent(selectedCandy, candy)) {
// Swap
isSwapping = true;
selectedCandy.setSelected(false);
var c1 = selectedCandy,
c2 = candy;
var pos1 = getCandyPos(c1.row, c1.col);
var pos2 = getCandyPos(c2.row, c2.col);
c1.animateSwap(pos2.x, pos2.y, function () {});
c2.animateSwap(pos1.x, pos1.y, function () {
swapCandies(c1, c2);
LK.getSound('swap').play();
// Check for matches
var matches = findMatches();
if (matches.length > 0) {
movesLeft--;
updateUI();
processMatches(matches);
} else {
// No matches, swap back and decrement moves
movesLeft--;
updateUI();
c1.animateSwap(pos1.x, pos1.y, function () {});
c2.animateSwap(pos2.x, pos2.y, function () {
swapCandies(c1, c2);
isSwapping = false;
});
}
clearSelection();
});
} else {
selectedCandy.setSelected(false);
selectedCandy = candy;
candy.setSelected(true);
}
}
// Process matches and cascades
// Combo system variables (global scope)
if (typeof comboCount === "undefined") {
var comboCount = 0;
var comboTimer = null;
}
// Visual effect for combo (global helper)
function showComboEffect(comboNum) {
if (comboNum <= 1) return;
var comboText = new Text2('Combo x' + comboNum, {
size: 110,
fill: '#fffa00',
font: "'GillSans-Bold',Impact,'Arial Black',Tahoma"
});
comboText.anchor.set(1, 0); // right-top
comboText.x = 2048 - 80;
comboText.y = 350;
comboText.alpha = 1;
LK.gui.topRight.addChild(comboText);
tween(comboText, {
y: comboText.y - 80,
alpha: 0
}, {
duration: 700,
easing: tween.cubicOut,
onFinish: function onFinish() {
comboText.destroy();
}
});
}
function processMatches(matches) {
isAnimating = true;
LK.getSound('match').play();
// Combo logic
if (typeof comboCount === "undefined") comboCount = 0;
comboCount++;
showComboEffect(comboCount);
// Score
var points = 0;
for (var i = 0; i < matches.length; i++) {
points += matches[i].length * 60 * comboCount;
}
score += points;
updateUI();
// Remove matches, refill, and check for cascades
removeMatches(matches, function () {
refillBoard(function () {
// Only allow further matches if they are valid (no random pops)
var newMatches = findMatches();
// Only process if the new matches are valid (length >= 3)
var validMatches = [];
for (var i = 0; i < newMatches.length; i++) {
if (newMatches[i].length >= 3) validMatches.push(newMatches[i]);
}
// Defensive: Only process matches if they are a result of a cascade (not random)
if (validMatches.length > 0) {
LK.getSound('cascade').play();
// Reset combo timer if running
if (comboTimer) LK.clearTimeout(comboTimer);
processMatches(validMatches);
} else {
// Combo chain ends, reset after a short delay
if (comboTimer) LK.clearTimeout(comboTimer);
comboTimer = LK.setTimeout(function () {
comboCount = 0;
}, 900);
isAnimating = false;
isSwapping = false;
if (!hasPossibleMoves()) {
shuffleBoard();
}
checkEnd();
}
});
});
}
// Touch/mouse events
game.down = function (x, y, obj) {
if (isAnimating || isSwapping) return;
// Only allow selection inside board area
if (x < BOARD_OFFSET_X || x > BOARD_OFFSET_X + BOARD_SIZE * CELL_SIZE || y < BOARD_OFFSET_Y || y > BOARD_OFFSET_Y + BOARD_SIZE * CELL_SIZE) return;
handleCandyDown(x, y, obj);
};
game.move = function (x, y, obj) {
// No drag/swipe for MVP
};
game.up = function (x, y, obj) {
// No drag/swipe for MVP
};
// Main update loop (not used for logic in MVP)
game.update = function () {
// No per-frame logic needed for MVP
};
// Start game
startGame();
blue candy. In-Game asset. 2d. High contrast. No shadows
black candy bomb. In-Game asset. 2d. High contrast. No shadows
candy green. In-Game asset. 2d. High contrast. No shadows
candy orange. In-Game asset. 2d. High contrast. No shadows
candy purple. In-Game asset. 2d. High contrast. No shadows
candy red. In-Game asset. 2d. High contrast. No shadows
yellow candy. In-Game asset. 2d. High contrast. No shadows