/****
* 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