/****
* Plugins
****/
var tween = LK.import("@upit/tween.v1");
/****
* Classes
****/
var Ball = Container.expand(function (value) {
var self = Container.call(this);
self.value = value || 2;
self.velocityX = 0;
self.velocityY = 0;
self.gravity = 0.8;
self.bounce = 0.4;
self.friction = 0.98;
// Set radius based on ball value for proper physics - reduced for tighter packing
var radiusMap = {
2: 90,
4: 95,
8: 105,
16: 115,
32: 125,
64: 135,
128: 145,
256: 155,
512: 165,
1024: 175,
2048: 185
};
self.radius = radiusMap[self.value] || 60;
self.isStatic = false;
self.mergeTimer = 0;
self.hasBeenMerged = false;
var ballAsset;
try {
ballAsset = self.attachAsset('ball' + self.value, {
anchorX: 0.5,
anchorY: 0.5
});
} catch (e) {
// Fallback for 2048 ball if image asset fails
if (self.value === 2048) {
ballAsset = self.attachAsset('ball2048top', {
anchorX: 0.5,
anchorY: 0.5
});
} else {
throw e;
}
}
var valueText = new Text2(self.value.toString(), {
size: 56,
fill: 0xFFFFFF
});
valueText.anchor.set(0.5, 0.5);
self.addChild(valueText);
self.update = function () {
if (self.hasBeenMerged) return;
if (self.mergeTimer > 0) {
self.mergeTimer--;
return;
}
// Apply physics only if not static
if (!self.isStatic) {
// Store previous position for continuous collision detection
var prevX = self.x;
var prevY = self.y;
// Apply gravity
self.velocityY += self.gravity;
// Calculate intended new position
var newX = self.x + self.velocityX;
var newY = self.y + self.velocityY;
// Continuous collision detection - check path from current to intended position
var stepCount = Math.max(1, Math.ceil(Math.abs(self.velocityX) + Math.abs(self.velocityY)) / (self.radius * 0.5));
var stepX = (newX - self.x) / stepCount;
var stepY = (newY - self.y) / stepCount;
var collisionOccurred = false;
// Check each step along the movement path
for (var step = 1; step <= stepCount && !collisionOccurred; step++) {
var testX = self.x + stepX * step;
var testY = self.y + stepY * step;
// Test collision with other balls
for (var i = 0; i < balls.length; i++) {
var otherBall = balls[i];
if (otherBall === self || otherBall.hasBeenMerged || otherBall.mergeTimer > 0) continue;
var dx = otherBall.x - testX;
var dy = otherBall.y - testY;
var distance = Math.sqrt(dx * dx + dy * dy);
var minDistance = (self.radius + otherBall.radius) * 0.94; // Slightly looser packing
if (distance < minDistance) {
// Collision detected - stop at safe position
var safeDistance = minDistance + 1; // Add smaller buffer for tighter packing
if (distance > 0) {
var normalX = dx / distance;
var normalY = dy / distance;
self.x = otherBall.x - normalX * safeDistance;
self.y = otherBall.y - normalY * safeDistance;
// Check for merge first - increased sensitivity for same-numbered balls
var mergeDistance = self.value === otherBall.value ? minDistance * 1.4 : minDistance;
if (self.value === otherBall.value && self.mergeTimer === 0 && otherBall.mergeTimer === 0 && distance < mergeDistance) {
// Special case: when two 2048 balls merge, they explode and disappear
if (self.value === 2048) {
// Create explosion effect for both 2048 balls
tween(self, {
scaleX: 3.0,
scaleY: 3.0,
alpha: 0
}, {
duration: 600,
easing: tween.easeOut
});
tween(otherBall, {
scaleX: 3.0,
scaleY: 3.0,
alpha: 0
}, {
duration: 600,
easing: tween.easeOut
});
// Mark both balls for removal
self.hasBeenMerged = true;
otherBall.hasBeenMerged = true;
LK.getSound('merge').play();
if (scoringActive) {
LK.setScore(LK.getScore() + 1);
scoreText.setText(LK.getScore());
}
return;
}
var newValue = self.value * 2;
if (newValue <= 2048) {
var newBall = new Ball(newValue);
newBall.x = (self.x + otherBall.x) / 2;
newBall.y = (self.y + otherBall.y) / 2;
newBall.velocityX = (self.velocityX + otherBall.velocityX) / 2;
newBall.velocityY = (self.velocityY + otherBall.velocityY) / 2;
newBall.mergeTimer = 10;
// Add merge animation - scale and pulse effect
newBall.scaleX = 0.2; // Start very small
newBall.scaleY = 0.2; // Start very small
newBall.alpha = 0.7; // Start semi-transparent
tween(newBall, {
scaleX: 1.3,
scaleY: 1.3,
alpha: 1
}, {
duration: 200,
easing: tween.easeOut,
onFinish: function onFinish() {
// Pulse back to normal size
tween(newBall, {
scaleX: 1,
scaleY: 1
}, {
duration: 150,
easing: tween.easeInOut
});
}
});
// Animate merging balls with shrink and fade
tween(self, {
scaleX: 0,
scaleY: 0,
alpha: 0
}, {
duration: 250,
easing: tween.easeIn
});
tween(otherBall, {
scaleX: 0,
scaleY: 0,
alpha: 0
}, {
duration: 250,
easing: tween.easeIn
});
balls.push(newBall);
gameArea.addChild(newBall);
// Mark for removal
self.hasBeenMerged = true;
otherBall.hasBeenMerged = true;
LK.getSound('merge').play();
if (scoringActive) {
LK.setScore(LK.getScore() + 1);
scoreText.setText(LK.getScore());
}
// No win condition - game continues even after reaching 2048
return;
}
}
// Enhanced collision response for better stacking
var relativeVelX = self.velocityX - otherBall.velocityX;
var relativeVelY = self.velocityY - otherBall.velocityY;
var relativeSpeed = relativeVelX * normalX + relativeVelY * normalY;
if (relativeSpeed > 0) {
// Calculate mass-based collision response (assume equal mass)
var restitution = 0.3; // Lower restitution for more stable stacking
var impulse = (1 + restitution) * relativeSpeed * 0.5;
self.velocityX -= impulse * normalX;
self.velocityY -= impulse * normalY;
otherBall.velocityX += impulse * normalX;
otherBall.velocityY += impulse * normalY;
// Apply different friction based on collision angle
var collisionAngle = Math.atan2(normalY, normalX);
var frictionFactor = Math.abs(Math.sin(collisionAngle)) * 0.8 + 0.2;
self.velocityX *= self.friction * frictionFactor;
self.velocityY *= self.friction * frictionFactor;
otherBall.velocityX *= otherBall.friction * frictionFactor;
otherBall.velocityY *= otherBall.friction * frictionFactor;
// Enhanced static detection for stacking
if (Math.abs(self.velocityY) < 0.5 && Math.abs(self.velocityX) < 0.5) {
// Check if ball is supported (has another ball below it or touching ground)
var supportFound = false;
var localBottom = gameAreaHeight - 20;
// Check ground support
if (self.y + self.radius >= localBottom - 5) {
supportFound = true;
} else {
// Check support from other balls
for (var j = 0; j < balls.length; j++) {
var supportBall = balls[j];
if (supportBall === self || supportBall.hasBeenMerged) continue;
var supportDx = supportBall.x - self.x;
var supportDy = supportBall.y - self.y;
var supportDistance = Math.sqrt(supportDx * supportDx + supportDy * supportDy);
// Check if this ball is resting on another ball (other ball is below)
if (supportDistance < (self.radius + supportBall.radius) * 1.1 && supportDy > 0) {
supportFound = true;
break;
}
}
}
if (supportFound) {
self.isStatic = true;
self.velocityX = 0;
self.velocityY = 0;
}
}
}
}
collisionOccurred = true;
break;
}
}
}
// If no collision occurred, move to intended position
if (!collisionOccurred) {
self.x = newX;
self.y = newY;
}
// Boundary collisions after position update
var localWidth = gameAreaRight - gameAreaLeft;
var localBottom = gameAreaHeight - 20; // gameAreaHeight minus bottom wall height
// Ground collision
if (self.y + self.radius > localBottom) {
self.y = localBottom - self.radius;
self.velocityY *= -self.bounce;
self.velocityX *= self.friction;
if (Math.abs(self.velocityY) < 1) {
self.velocityY = 0;
self.isStatic = true;
}
}
// Side walls collision
if (self.x - self.radius < 0) {
self.x = self.radius;
self.velocityX *= -self.bounce;
}
if (self.x + self.radius > localWidth) {
self.x = localWidth - self.radius;
self.velocityX *= -self.bounce;
}
}
// Enhanced merge detection pass - check for nearby same-numbered balls
for (var i = 0; i < balls.length; i++) {
var otherBall = balls[i];
if (otherBall === self || otherBall.hasBeenMerged || otherBall.mergeTimer > 0 || self.mergeTimer > 0) continue;
var dx = otherBall.x - self.x;
var dy = otherBall.y - self.y;
var distance = Math.sqrt(dx * dx + dy * dy);
var minDistance = (self.radius + otherBall.radius) * 0.94; // Slightly looser packing
// Enhanced merge sensitivity for same-numbered balls
if (self.value === otherBall.value) {
var mergeTriggerDistance = minDistance * 1.4; // Increased sensitivity to compensate for reduced minDistance
if (distance < mergeTriggerDistance) {
// Special case: when two 2048 balls merge, they explode and disappear
if (self.value === 2048) {
// Create explosion effect for both 2048 balls
tween(self, {
scaleX: 3.0,
scaleY: 3.0,
alpha: 0
}, {
duration: 600,
easing: tween.easeOut
});
tween(otherBall, {
scaleX: 3.0,
scaleY: 3.0,
alpha: 0
}, {
duration: 600,
easing: tween.easeOut
});
// Mark both balls for removal
self.hasBeenMerged = true;
otherBall.hasBeenMerged = true;
LK.getSound('merge').play();
if (scoringActive) {
LK.setScore(LK.getScore() + 1);
scoreText.setText(LK.getScore());
}
return;
}
var newValue = self.value * 2;
if (newValue <= 2048) {
var newBall = new Ball(newValue);
newBall.x = (self.x + otherBall.x) / 2;
newBall.y = (self.y + otherBall.y) / 2;
newBall.velocityX = (self.velocityX + otherBall.velocityX) / 2;
newBall.velocityY = (self.velocityY + otherBall.velocityY) / 2;
newBall.mergeTimer = 10;
// Add merge animation - scale and pulse effect
newBall.scaleX = 0.2; // Start very small
newBall.scaleY = 0.2; // Start very small
newBall.alpha = 0.7; // Start semi-transparent
tween(newBall, {
scaleX: 1.3,
scaleY: 1.3,
alpha: 1
}, {
duration: 200,
easing: tween.easeOut,
onFinish: function onFinish() {
// Pulse back to normal size
tween(newBall, {
scaleX: 1,
scaleY: 1
}, {
duration: 150,
easing: tween.easeInOut
});
}
});
// Animate merging balls with shrink and fade
tween(self, {
scaleX: 0,
scaleY: 0,
alpha: 0
}, {
duration: 250,
easing: tween.easeIn
});
tween(otherBall, {
scaleX: 0,
scaleY: 0,
alpha: 0
}, {
duration: 250,
easing: tween.easeIn
});
balls.push(newBall);
gameArea.addChild(newBall);
// Mark for removal
self.hasBeenMerged = true;
otherBall.hasBeenMerged = true;
LK.getSound('merge').play();
if (scoringActive) {
LK.setScore(LK.getScore() + 1);
scoreText.setText(LK.getScore());
}
// No win condition - game continues even after reaching 2048
return;
}
}
}
// Enhanced separation for stable stacking
if (distance < minDistance && distance > 0) {
var overlap = minDistance - distance;
var normalX = dx / distance;
var normalY = dy / distance;
// Different separation strategies based on ball positions
var separationForce = overlap * 0.5;
// If one ball is static and the other is moving, adjust separation
if (self.isStatic && !otherBall.isStatic) {
// Static ball moves less
self.x -= normalX * separationForce * 0.2;
self.y -= normalY * separationForce * 0.2;
otherBall.x += normalX * separationForce * 0.8;
otherBall.y += normalY * separationForce * 0.8;
} else if (!self.isStatic && otherBall.isStatic) {
// Other ball is static
self.x -= normalX * separationForce * 0.8;
self.y -= normalY * separationForce * 0.8;
otherBall.x += normalX * separationForce * 0.2;
otherBall.y += normalY * separationForce * 0.2;
} else {
// Both moving or both static - equal separation
self.x -= normalX * separationForce;
self.y -= normalY * separationForce;
otherBall.x += normalX * separationForce;
otherBall.y += normalY * separationForce;
}
// Ensure balls stay within bounds after separation
var localWidth = gameAreaRight - gameAreaLeft;
var localBottom = gameAreaHeight - 20; // gameAreaHeight minus bottom wall height
self.x = Math.max(self.radius, Math.min(localWidth - self.radius, self.x));
self.y = Math.max(self.radius, Math.min(localBottom - self.radius, self.y));
// Re-check static state after separation
if (self.isStatic && (Math.abs(self.x - (self.x - normalX * separationForce)) > 1 || Math.abs(self.y - (self.y - normalY * separationForce)) > 1)) {
self.isStatic = false; // Ball was moved significantly, make it dynamic again
}
}
}
// Periodic re-evaluation of static state for better stacking
if (LK.ticks % 10 === 0 && !self.hasBeenMerged) {
// Check every 10 frames
if (!self.isStatic && Math.abs(self.velocityX) < 0.3 && Math.abs(self.velocityY) < 0.3) {
// Check if ball should become static
var supportFound = false;
var localBottom = gameAreaHeight - 20;
// Check ground support
if (self.y + self.radius >= localBottom - 2) {
supportFound = true;
} else {
// Check support from other static balls
for (var k = 0; k < balls.length; k++) {
var checkBall = balls[k];
if (checkBall === self || checkBall.hasBeenMerged || !checkBall.isStatic) continue;
var checkDx = checkBall.x - self.x;
var checkDy = checkBall.y - self.y;
var checkDistance = Math.sqrt(checkDx * checkDx + checkDy * checkDy);
// Ball is resting on another static ball
if (checkDistance < (self.radius + checkBall.radius) * 1.05 && checkDy > 0) {
supportFound = true;
break;
}
}
}
if (supportFound) {
self.isStatic = true;
self.velocityX = 0;
self.velocityY = 0;
}
} else if (self.isStatic) {
// Check if static ball should become dynamic (lost support)
var stillSupported = false;
var localBottom = gameAreaHeight - 20;
// Check ground support
if (self.y + self.radius >= localBottom - 2) {
stillSupported = true;
} else {
// Check support from other balls
for (var k = 0; k < balls.length; k++) {
var supportBall = balls[k];
if (supportBall === self || supportBall.hasBeenMerged) continue;
var supportDx = supportBall.x - self.x;
var supportDy = supportBall.y - self.y;
var supportDistance = Math.sqrt(supportDx * supportDx + supportDy * supportDy);
if (supportDistance < (self.radius + supportBall.radius) * 1.05 && supportDy > 0) {
stillSupported = true;
break;
}
}
}
if (!stillSupported) {
self.isStatic = false; // Ball lost support, make it fall
}
}
}
};
return self;
});
/****
* Initialize Game
****/
var game = new LK.Game({
backgroundColor: 0xf5f5dc
});
/****
* Game Code
****/
var balls = [];
var nextBallValue = 2;
var gameAreaLeft = 0;
var gameAreaRight = 2048;
var gameAreaTop = 0;
var gameAreaBottom = 2732;
var dropCooldown = 0;
var gameAreaHeight = gameAreaBottom - gameAreaTop;
var dangerZoneImmunity = 300; // 5 seconds at 60fps
var scoringActive = false; // Flag to track when scoring should be active
// Create game area background
var gameAreaBg = game.addChild(LK.getAsset('gameArea', {
anchorX: 0.5,
anchorY: 0,
x: 1024,
y: gameAreaTop
}));
// Create game area container for balls
var gameArea = new Container();
gameArea.x = gameAreaLeft;
gameArea.y = gameAreaTop;
game.addChild(gameArea);
// Create visible walls
var leftWall = game.addChild(LK.getAsset('leftWall', {
anchorX: 1,
anchorY: 0,
x: gameAreaLeft,
y: gameAreaTop
}));
var rightWall = game.addChild(LK.getAsset('rightWall', {
anchorX: 0,
anchorY: 0,
x: gameAreaRight,
y: gameAreaTop
}));
var bottomWall = game.addChild(LK.getAsset('bottomWall', {
anchorX: 0.5,
anchorY: 0,
x: 1024,
y: gameAreaBottom
}));
// Create danger zone line (visible red line showing danger threshold)
var dangerZoneLine = game.addChild(LK.getAsset('gameOverLine', {
anchorX: 0.5,
anchorY: 0.5,
x: 1024,
y: gameAreaTop + 350,
alpha: 0.8,
visible: true
}));
// Create score display
var scoreText = new Text2('0', {
size: 100,
fill: 0xFFFFFF
});
scoreText.anchor.set(1, 0);
LK.gui.topRight.addChild(scoreText);
scoreText.x = -50; // Small offset from right edge
scoreText.y = 100;
// Create next ball preview
var nextBallPreview = LK.getAsset('ball' + nextBallValue, {
anchorX: 0.5,
anchorY: 1.0,
x: 1024,
y: gameAreaTop + 350,
scaleX: 1.4,
scaleY: 1.4
});
game.addChild(nextBallPreview);
// Create next ball value text
var nextBallText = new Text2(nextBallValue.toString(), {
size: 48,
fill: 0xFFFFFF
});
nextBallText.anchor.set(0.5, 0.5);
nextBallText.x = 1024;
nextBallText.y = gameAreaTop + 200;
game.addChild(nextBallText);
function getRandomNextBallValue() {
var values = [2, 4, 8];
return values[Math.floor(Math.random() * values.length)];
}
function updateNextBallPreview() {
game.removeChild(nextBallPreview);
game.removeChild(nextBallText);
nextBallPreview = LK.getAsset('ball' + nextBallValue, {
anchorX: 0.5,
anchorY: 1.0,
x: 1024,
y: gameAreaTop + 350,
scaleX: 1.4,
scaleY: 1.4
});
game.addChild(nextBallPreview);
nextBallText = new Text2(nextBallValue.toString(), {
size: 48,
fill: 0xFFFFFF
});
nextBallText.anchor.set(0.5, 0.5);
nextBallText.x = 1024;
nextBallText.y = gameAreaTop + 200;
game.addChild(nextBallText);
}
function checkGameOver() {
// Skip game over check during immunity period
if (dangerZoneImmunity > 0) return;
for (var i = 0; i < balls.length; i++) {
// Check if ball touches the danger zone (first 350 pixels of game area)
// Since balls are in gameArea container, we use local coordinates
// Only trigger game over if ball is static (not actively falling) and in danger zone
if (balls[i].y - balls[i].radius <= 350 && balls[i].isStatic) {
LK.showGameOver();
return;
}
}
}
function cleanupMergedBalls() {
for (var i = balls.length - 1; i >= 0; i--) {
if (balls[i].hasBeenMerged) {
gameArea.removeChild(balls[i]);
balls[i].destroy();
balls.splice(i, 1);
}
}
}
// Create initial balls to fill container to the top
function createInitialBalls() {
var localWidth = gameAreaRight - gameAreaLeft;
var ballsPerRow = 7; // Fixed number of balls per row for consistent layout
var rowBalls = []; // Track balls in current row for spacing calculation
var rowHeights = []; // Track row heights for stacking
var previousRowBalls = []; // Track balls from previous row for vertical spacing
for (var row = 0; row < 12; row++) {
rowBalls = []; // Reset for each row
var maxRadiusInRow = 0; // Track largest ball in current row
// Create balls for this row first to know their sizes
var ballsData = [];
for (var col = 0; col < ballsPerRow; col++) {
var ballValue = getRandomNextBallValue();
var radiusMap = {
2: 90,
4: 95,
8: 105,
16: 115,
32: 125,
64: 135,
128: 145,
256: 155,
512: 165,
1024: 175,
2048: 185
};
var ballRadius = radiusMap[ballValue] || 60;
ballsData.push({
value: ballValue,
radius: ballRadius
});
maxRadiusInRow = Math.max(maxRadiusInRow, ballRadius);
}
// Calculate X positions to make balls touch tightly horizontally - subtract overlap for tighter packing
var xPositions = [];
var currentX = 0;
var overlapAmount = 8; // Slightly reduced overlap for looser horizontal packing
for (var col = 0; col < ballsPerRow; col++) {
if (col === 0) {
// First ball starts at its radius
currentX = ballsData[col].radius;
} else {
// Each subsequent ball is positioned closer than touching distance
var prevRadius = ballsData[col - 1].radius;
var currentRadius = ballsData[col].radius;
currentX += prevRadius + currentRadius - overlapAmount; // Reduce spacing for tighter packing
}
xPositions.push(currentX);
}
// Center the entire row
var totalRowWidth = xPositions[xPositions.length - 1] + ballsData[ballsPerRow - 1].radius;
var offsetX = (localWidth - totalRowWidth) / 2;
// Create and position the balls
for (var col = 0; col < ballsPerRow; col++) {
var initialBall = new Ball(ballsData[col].value);
initialBall.x = xPositions[col] + offsetX;
initialBall.velocityX = 0;
initialBall.velocityY = 0;
initialBall.isStatic = true; // Start as static for stable stacking
rowBalls.push(initialBall);
balls.push(initialBall);
gameArea.addChild(initialBall);
}
// Calculate Y position to make balls touch tightly vertically with previous row
var rowY;
var verticalOverlap = 4; // Slightly reduced vertical overlap for looser packing
if (row === 0) {
// First row sits on bottom
rowY = gameAreaHeight - 100 - maxRadiusInRow;
} else {
// For subsequent rows, find the minimum Y position with tighter packing
rowY = gameAreaHeight; // Start with max possible Y
for (var currentCol = 0; currentCol < rowBalls.length; currentCol++) {
var currentBall = rowBalls[currentCol];
var currentBallRadius = currentBall.radius;
var minYForThisBall = gameAreaHeight - 100 - currentBallRadius; // Default to bottom
// Check against all balls in previous row
for (var prevCol = 0; prevCol < previousRowBalls.length; prevCol++) {
var prevBall = previousRowBalls[prevCol];
var dx = currentBall.x - prevBall.x;
var dy = 0; // We'll calculate the required dy
var horizontalDistance = Math.abs(dx);
var requiredCenterDistance = currentBallRadius + prevBall.radius - verticalOverlap; // Tighter vertical packing
// If balls are close enough horizontally to potentially touch
if (horizontalDistance < requiredCenterDistance) {
// Calculate the vertical distance needed for tighter packing
var verticalDistance = Math.sqrt(Math.max(0, requiredCenterDistance * requiredCenterDistance - dx * dx));
var requiredY = prevBall.y - verticalDistance;
minYForThisBall = Math.min(minYForThisBall, requiredY);
}
}
// The row Y is determined by the ball that needs to be highest (smallest Y)
rowY = Math.min(rowY, minYForThisBall);
}
}
// Apply the calculated Y position to all balls in this row
for (var j = 0; j < rowBalls.length; j++) {
rowBalls[j].y = rowY;
}
// Store this row's balls for next row calculation
previousRowBalls = rowBalls.slice(); // Copy the array
}
}
// Initialize the starting balls
createInitialBalls();
game.down = function (x, y, obj) {
if (dropCooldown > 0) return;
// Activate scoring system when first ball is dropped
if (!scoringActive) {
scoringActive = true;
}
// Constrain drop position to game area (convert to local coordinates)
var localWidth = gameAreaRight - gameAreaLeft;
var localX = x - gameAreaLeft;
var dropX = Math.max(80, Math.min(localWidth - 80, localX));
var newBall = new Ball(nextBallValue);
newBall.x = dropX;
newBall.y = 420; // Start below the danger zone (350px) plus ball radius (60px) plus buffer
newBall.velocityX = 0;
newBall.velocityY = 0;
balls.push(newBall);
gameArea.addChild(newBall);
LK.getSound('drop').play();
// Update next ball
nextBallValue = getRandomNextBallValue();
updateNextBallPreview();
dropCooldown = 30; // 0.5 seconds at 60fps
};
game.update = function () {
if (dropCooldown > 0) {
dropCooldown--;
}
// Handle danger zone immunity countdown
if (dangerZoneImmunity > 0) {
dangerZoneImmunity--;
}
cleanupMergedBalls();
checkGameOver();
// Update score display
scoreText.setText(LK.getScore());
}; /****
* Plugins
****/
var tween = LK.import("@upit/tween.v1");
/****
* Classes
****/
var Ball = Container.expand(function (value) {
var self = Container.call(this);
self.value = value || 2;
self.velocityX = 0;
self.velocityY = 0;
self.gravity = 0.8;
self.bounce = 0.4;
self.friction = 0.98;
// Set radius based on ball value for proper physics - reduced for tighter packing
var radiusMap = {
2: 90,
4: 95,
8: 105,
16: 115,
32: 125,
64: 135,
128: 145,
256: 155,
512: 165,
1024: 175,
2048: 185
};
self.radius = radiusMap[self.value] || 60;
self.isStatic = false;
self.mergeTimer = 0;
self.hasBeenMerged = false;
var ballAsset;
try {
ballAsset = self.attachAsset('ball' + self.value, {
anchorX: 0.5,
anchorY: 0.5
});
} catch (e) {
// Fallback for 2048 ball if image asset fails
if (self.value === 2048) {
ballAsset = self.attachAsset('ball2048top', {
anchorX: 0.5,
anchorY: 0.5
});
} else {
throw e;
}
}
var valueText = new Text2(self.value.toString(), {
size: 56,
fill: 0xFFFFFF
});
valueText.anchor.set(0.5, 0.5);
self.addChild(valueText);
self.update = function () {
if (self.hasBeenMerged) return;
if (self.mergeTimer > 0) {
self.mergeTimer--;
return;
}
// Apply physics only if not static
if (!self.isStatic) {
// Store previous position for continuous collision detection
var prevX = self.x;
var prevY = self.y;
// Apply gravity
self.velocityY += self.gravity;
// Calculate intended new position
var newX = self.x + self.velocityX;
var newY = self.y + self.velocityY;
// Continuous collision detection - check path from current to intended position
var stepCount = Math.max(1, Math.ceil(Math.abs(self.velocityX) + Math.abs(self.velocityY)) / (self.radius * 0.5));
var stepX = (newX - self.x) / stepCount;
var stepY = (newY - self.y) / stepCount;
var collisionOccurred = false;
// Check each step along the movement path
for (var step = 1; step <= stepCount && !collisionOccurred; step++) {
var testX = self.x + stepX * step;
var testY = self.y + stepY * step;
// Test collision with other balls
for (var i = 0; i < balls.length; i++) {
var otherBall = balls[i];
if (otherBall === self || otherBall.hasBeenMerged || otherBall.mergeTimer > 0) continue;
var dx = otherBall.x - testX;
var dy = otherBall.y - testY;
var distance = Math.sqrt(dx * dx + dy * dy);
var minDistance = (self.radius + otherBall.radius) * 0.94; // Slightly looser packing
if (distance < minDistance) {
// Collision detected - stop at safe position
var safeDistance = minDistance + 1; // Add smaller buffer for tighter packing
if (distance > 0) {
var normalX = dx / distance;
var normalY = dy / distance;
self.x = otherBall.x - normalX * safeDistance;
self.y = otherBall.y - normalY * safeDistance;
// Check for merge first - increased sensitivity for same-numbered balls
var mergeDistance = self.value === otherBall.value ? minDistance * 1.4 : minDistance;
if (self.value === otherBall.value && self.mergeTimer === 0 && otherBall.mergeTimer === 0 && distance < mergeDistance) {
// Special case: when two 2048 balls merge, they explode and disappear
if (self.value === 2048) {
// Create explosion effect for both 2048 balls
tween(self, {
scaleX: 3.0,
scaleY: 3.0,
alpha: 0
}, {
duration: 600,
easing: tween.easeOut
});
tween(otherBall, {
scaleX: 3.0,
scaleY: 3.0,
alpha: 0
}, {
duration: 600,
easing: tween.easeOut
});
// Mark both balls for removal
self.hasBeenMerged = true;
otherBall.hasBeenMerged = true;
LK.getSound('merge').play();
if (scoringActive) {
LK.setScore(LK.getScore() + 1);
scoreText.setText(LK.getScore());
}
return;
}
var newValue = self.value * 2;
if (newValue <= 2048) {
var newBall = new Ball(newValue);
newBall.x = (self.x + otherBall.x) / 2;
newBall.y = (self.y + otherBall.y) / 2;
newBall.velocityX = (self.velocityX + otherBall.velocityX) / 2;
newBall.velocityY = (self.velocityY + otherBall.velocityY) / 2;
newBall.mergeTimer = 10;
// Add merge animation - scale and pulse effect
newBall.scaleX = 0.2; // Start very small
newBall.scaleY = 0.2; // Start very small
newBall.alpha = 0.7; // Start semi-transparent
tween(newBall, {
scaleX: 1.3,
scaleY: 1.3,
alpha: 1
}, {
duration: 200,
easing: tween.easeOut,
onFinish: function onFinish() {
// Pulse back to normal size
tween(newBall, {
scaleX: 1,
scaleY: 1
}, {
duration: 150,
easing: tween.easeInOut
});
}
});
// Animate merging balls with shrink and fade
tween(self, {
scaleX: 0,
scaleY: 0,
alpha: 0
}, {
duration: 250,
easing: tween.easeIn
});
tween(otherBall, {
scaleX: 0,
scaleY: 0,
alpha: 0
}, {
duration: 250,
easing: tween.easeIn
});
balls.push(newBall);
gameArea.addChild(newBall);
// Mark for removal
self.hasBeenMerged = true;
otherBall.hasBeenMerged = true;
LK.getSound('merge').play();
if (scoringActive) {
LK.setScore(LK.getScore() + 1);
scoreText.setText(LK.getScore());
}
// No win condition - game continues even after reaching 2048
return;
}
}
// Enhanced collision response for better stacking
var relativeVelX = self.velocityX - otherBall.velocityX;
var relativeVelY = self.velocityY - otherBall.velocityY;
var relativeSpeed = relativeVelX * normalX + relativeVelY * normalY;
if (relativeSpeed > 0) {
// Calculate mass-based collision response (assume equal mass)
var restitution = 0.3; // Lower restitution for more stable stacking
var impulse = (1 + restitution) * relativeSpeed * 0.5;
self.velocityX -= impulse * normalX;
self.velocityY -= impulse * normalY;
otherBall.velocityX += impulse * normalX;
otherBall.velocityY += impulse * normalY;
// Apply different friction based on collision angle
var collisionAngle = Math.atan2(normalY, normalX);
var frictionFactor = Math.abs(Math.sin(collisionAngle)) * 0.8 + 0.2;
self.velocityX *= self.friction * frictionFactor;
self.velocityY *= self.friction * frictionFactor;
otherBall.velocityX *= otherBall.friction * frictionFactor;
otherBall.velocityY *= otherBall.friction * frictionFactor;
// Enhanced static detection for stacking
if (Math.abs(self.velocityY) < 0.5 && Math.abs(self.velocityX) < 0.5) {
// Check if ball is supported (has another ball below it or touching ground)
var supportFound = false;
var localBottom = gameAreaHeight - 20;
// Check ground support
if (self.y + self.radius >= localBottom - 5) {
supportFound = true;
} else {
// Check support from other balls
for (var j = 0; j < balls.length; j++) {
var supportBall = balls[j];
if (supportBall === self || supportBall.hasBeenMerged) continue;
var supportDx = supportBall.x - self.x;
var supportDy = supportBall.y - self.y;
var supportDistance = Math.sqrt(supportDx * supportDx + supportDy * supportDy);
// Check if this ball is resting on another ball (other ball is below)
if (supportDistance < (self.radius + supportBall.radius) * 1.1 && supportDy > 0) {
supportFound = true;
break;
}
}
}
if (supportFound) {
self.isStatic = true;
self.velocityX = 0;
self.velocityY = 0;
}
}
}
}
collisionOccurred = true;
break;
}
}
}
// If no collision occurred, move to intended position
if (!collisionOccurred) {
self.x = newX;
self.y = newY;
}
// Boundary collisions after position update
var localWidth = gameAreaRight - gameAreaLeft;
var localBottom = gameAreaHeight - 20; // gameAreaHeight minus bottom wall height
// Ground collision
if (self.y + self.radius > localBottom) {
self.y = localBottom - self.radius;
self.velocityY *= -self.bounce;
self.velocityX *= self.friction;
if (Math.abs(self.velocityY) < 1) {
self.velocityY = 0;
self.isStatic = true;
}
}
// Side walls collision
if (self.x - self.radius < 0) {
self.x = self.radius;
self.velocityX *= -self.bounce;
}
if (self.x + self.radius > localWidth) {
self.x = localWidth - self.radius;
self.velocityX *= -self.bounce;
}
}
// Enhanced merge detection pass - check for nearby same-numbered balls
for (var i = 0; i < balls.length; i++) {
var otherBall = balls[i];
if (otherBall === self || otherBall.hasBeenMerged || otherBall.mergeTimer > 0 || self.mergeTimer > 0) continue;
var dx = otherBall.x - self.x;
var dy = otherBall.y - self.y;
var distance = Math.sqrt(dx * dx + dy * dy);
var minDistance = (self.radius + otherBall.radius) * 0.94; // Slightly looser packing
// Enhanced merge sensitivity for same-numbered balls
if (self.value === otherBall.value) {
var mergeTriggerDistance = minDistance * 1.4; // Increased sensitivity to compensate for reduced minDistance
if (distance < mergeTriggerDistance) {
// Special case: when two 2048 balls merge, they explode and disappear
if (self.value === 2048) {
// Create explosion effect for both 2048 balls
tween(self, {
scaleX: 3.0,
scaleY: 3.0,
alpha: 0
}, {
duration: 600,
easing: tween.easeOut
});
tween(otherBall, {
scaleX: 3.0,
scaleY: 3.0,
alpha: 0
}, {
duration: 600,
easing: tween.easeOut
});
// Mark both balls for removal
self.hasBeenMerged = true;
otherBall.hasBeenMerged = true;
LK.getSound('merge').play();
if (scoringActive) {
LK.setScore(LK.getScore() + 1);
scoreText.setText(LK.getScore());
}
return;
}
var newValue = self.value * 2;
if (newValue <= 2048) {
var newBall = new Ball(newValue);
newBall.x = (self.x + otherBall.x) / 2;
newBall.y = (self.y + otherBall.y) / 2;
newBall.velocityX = (self.velocityX + otherBall.velocityX) / 2;
newBall.velocityY = (self.velocityY + otherBall.velocityY) / 2;
newBall.mergeTimer = 10;
// Add merge animation - scale and pulse effect
newBall.scaleX = 0.2; // Start very small
newBall.scaleY = 0.2; // Start very small
newBall.alpha = 0.7; // Start semi-transparent
tween(newBall, {
scaleX: 1.3,
scaleY: 1.3,
alpha: 1
}, {
duration: 200,
easing: tween.easeOut,
onFinish: function onFinish() {
// Pulse back to normal size
tween(newBall, {
scaleX: 1,
scaleY: 1
}, {
duration: 150,
easing: tween.easeInOut
});
}
});
// Animate merging balls with shrink and fade
tween(self, {
scaleX: 0,
scaleY: 0,
alpha: 0
}, {
duration: 250,
easing: tween.easeIn
});
tween(otherBall, {
scaleX: 0,
scaleY: 0,
alpha: 0
}, {
duration: 250,
easing: tween.easeIn
});
balls.push(newBall);
gameArea.addChild(newBall);
// Mark for removal
self.hasBeenMerged = true;
otherBall.hasBeenMerged = true;
LK.getSound('merge').play();
if (scoringActive) {
LK.setScore(LK.getScore() + 1);
scoreText.setText(LK.getScore());
}
// No win condition - game continues even after reaching 2048
return;
}
}
}
// Enhanced separation for stable stacking
if (distance < minDistance && distance > 0) {
var overlap = minDistance - distance;
var normalX = dx / distance;
var normalY = dy / distance;
// Different separation strategies based on ball positions
var separationForce = overlap * 0.5;
// If one ball is static and the other is moving, adjust separation
if (self.isStatic && !otherBall.isStatic) {
// Static ball moves less
self.x -= normalX * separationForce * 0.2;
self.y -= normalY * separationForce * 0.2;
otherBall.x += normalX * separationForce * 0.8;
otherBall.y += normalY * separationForce * 0.8;
} else if (!self.isStatic && otherBall.isStatic) {
// Other ball is static
self.x -= normalX * separationForce * 0.8;
self.y -= normalY * separationForce * 0.8;
otherBall.x += normalX * separationForce * 0.2;
otherBall.y += normalY * separationForce * 0.2;
} else {
// Both moving or both static - equal separation
self.x -= normalX * separationForce;
self.y -= normalY * separationForce;
otherBall.x += normalX * separationForce;
otherBall.y += normalY * separationForce;
}
// Ensure balls stay within bounds after separation
var localWidth = gameAreaRight - gameAreaLeft;
var localBottom = gameAreaHeight - 20; // gameAreaHeight minus bottom wall height
self.x = Math.max(self.radius, Math.min(localWidth - self.radius, self.x));
self.y = Math.max(self.radius, Math.min(localBottom - self.radius, self.y));
// Re-check static state after separation
if (self.isStatic && (Math.abs(self.x - (self.x - normalX * separationForce)) > 1 || Math.abs(self.y - (self.y - normalY * separationForce)) > 1)) {
self.isStatic = false; // Ball was moved significantly, make it dynamic again
}
}
}
// Periodic re-evaluation of static state for better stacking
if (LK.ticks % 10 === 0 && !self.hasBeenMerged) {
// Check every 10 frames
if (!self.isStatic && Math.abs(self.velocityX) < 0.3 && Math.abs(self.velocityY) < 0.3) {
// Check if ball should become static
var supportFound = false;
var localBottom = gameAreaHeight - 20;
// Check ground support
if (self.y + self.radius >= localBottom - 2) {
supportFound = true;
} else {
// Check support from other static balls
for (var k = 0; k < balls.length; k++) {
var checkBall = balls[k];
if (checkBall === self || checkBall.hasBeenMerged || !checkBall.isStatic) continue;
var checkDx = checkBall.x - self.x;
var checkDy = checkBall.y - self.y;
var checkDistance = Math.sqrt(checkDx * checkDx + checkDy * checkDy);
// Ball is resting on another static ball
if (checkDistance < (self.radius + checkBall.radius) * 1.05 && checkDy > 0) {
supportFound = true;
break;
}
}
}
if (supportFound) {
self.isStatic = true;
self.velocityX = 0;
self.velocityY = 0;
}
} else if (self.isStatic) {
// Check if static ball should become dynamic (lost support)
var stillSupported = false;
var localBottom = gameAreaHeight - 20;
// Check ground support
if (self.y + self.radius >= localBottom - 2) {
stillSupported = true;
} else {
// Check support from other balls
for (var k = 0; k < balls.length; k++) {
var supportBall = balls[k];
if (supportBall === self || supportBall.hasBeenMerged) continue;
var supportDx = supportBall.x - self.x;
var supportDy = supportBall.y - self.y;
var supportDistance = Math.sqrt(supportDx * supportDx + supportDy * supportDy);
if (supportDistance < (self.radius + supportBall.radius) * 1.05 && supportDy > 0) {
stillSupported = true;
break;
}
}
}
if (!stillSupported) {
self.isStatic = false; // Ball lost support, make it fall
}
}
}
};
return self;
});
/****
* Initialize Game
****/
var game = new LK.Game({
backgroundColor: 0xf5f5dc
});
/****
* Game Code
****/
var balls = [];
var nextBallValue = 2;
var gameAreaLeft = 0;
var gameAreaRight = 2048;
var gameAreaTop = 0;
var gameAreaBottom = 2732;
var dropCooldown = 0;
var gameAreaHeight = gameAreaBottom - gameAreaTop;
var dangerZoneImmunity = 300; // 5 seconds at 60fps
var scoringActive = false; // Flag to track when scoring should be active
// Create game area background
var gameAreaBg = game.addChild(LK.getAsset('gameArea', {
anchorX: 0.5,
anchorY: 0,
x: 1024,
y: gameAreaTop
}));
// Create game area container for balls
var gameArea = new Container();
gameArea.x = gameAreaLeft;
gameArea.y = gameAreaTop;
game.addChild(gameArea);
// Create visible walls
var leftWall = game.addChild(LK.getAsset('leftWall', {
anchorX: 1,
anchorY: 0,
x: gameAreaLeft,
y: gameAreaTop
}));
var rightWall = game.addChild(LK.getAsset('rightWall', {
anchorX: 0,
anchorY: 0,
x: gameAreaRight,
y: gameAreaTop
}));
var bottomWall = game.addChild(LK.getAsset('bottomWall', {
anchorX: 0.5,
anchorY: 0,
x: 1024,
y: gameAreaBottom
}));
// Create danger zone line (visible red line showing danger threshold)
var dangerZoneLine = game.addChild(LK.getAsset('gameOverLine', {
anchorX: 0.5,
anchorY: 0.5,
x: 1024,
y: gameAreaTop + 350,
alpha: 0.8,
visible: true
}));
// Create score display
var scoreText = new Text2('0', {
size: 100,
fill: 0xFFFFFF
});
scoreText.anchor.set(1, 0);
LK.gui.topRight.addChild(scoreText);
scoreText.x = -50; // Small offset from right edge
scoreText.y = 100;
// Create next ball preview
var nextBallPreview = LK.getAsset('ball' + nextBallValue, {
anchorX: 0.5,
anchorY: 1.0,
x: 1024,
y: gameAreaTop + 350,
scaleX: 1.4,
scaleY: 1.4
});
game.addChild(nextBallPreview);
// Create next ball value text
var nextBallText = new Text2(nextBallValue.toString(), {
size: 48,
fill: 0xFFFFFF
});
nextBallText.anchor.set(0.5, 0.5);
nextBallText.x = 1024;
nextBallText.y = gameAreaTop + 200;
game.addChild(nextBallText);
function getRandomNextBallValue() {
var values = [2, 4, 8];
return values[Math.floor(Math.random() * values.length)];
}
function updateNextBallPreview() {
game.removeChild(nextBallPreview);
game.removeChild(nextBallText);
nextBallPreview = LK.getAsset('ball' + nextBallValue, {
anchorX: 0.5,
anchorY: 1.0,
x: 1024,
y: gameAreaTop + 350,
scaleX: 1.4,
scaleY: 1.4
});
game.addChild(nextBallPreview);
nextBallText = new Text2(nextBallValue.toString(), {
size: 48,
fill: 0xFFFFFF
});
nextBallText.anchor.set(0.5, 0.5);
nextBallText.x = 1024;
nextBallText.y = gameAreaTop + 200;
game.addChild(nextBallText);
}
function checkGameOver() {
// Skip game over check during immunity period
if (dangerZoneImmunity > 0) return;
for (var i = 0; i < balls.length; i++) {
// Check if ball touches the danger zone (first 350 pixels of game area)
// Since balls are in gameArea container, we use local coordinates
// Only trigger game over if ball is static (not actively falling) and in danger zone
if (balls[i].y - balls[i].radius <= 350 && balls[i].isStatic) {
LK.showGameOver();
return;
}
}
}
function cleanupMergedBalls() {
for (var i = balls.length - 1; i >= 0; i--) {
if (balls[i].hasBeenMerged) {
gameArea.removeChild(balls[i]);
balls[i].destroy();
balls.splice(i, 1);
}
}
}
// Create initial balls to fill container to the top
function createInitialBalls() {
var localWidth = gameAreaRight - gameAreaLeft;
var ballsPerRow = 7; // Fixed number of balls per row for consistent layout
var rowBalls = []; // Track balls in current row for spacing calculation
var rowHeights = []; // Track row heights for stacking
var previousRowBalls = []; // Track balls from previous row for vertical spacing
for (var row = 0; row < 12; row++) {
rowBalls = []; // Reset for each row
var maxRadiusInRow = 0; // Track largest ball in current row
// Create balls for this row first to know their sizes
var ballsData = [];
for (var col = 0; col < ballsPerRow; col++) {
var ballValue = getRandomNextBallValue();
var radiusMap = {
2: 90,
4: 95,
8: 105,
16: 115,
32: 125,
64: 135,
128: 145,
256: 155,
512: 165,
1024: 175,
2048: 185
};
var ballRadius = radiusMap[ballValue] || 60;
ballsData.push({
value: ballValue,
radius: ballRadius
});
maxRadiusInRow = Math.max(maxRadiusInRow, ballRadius);
}
// Calculate X positions to make balls touch tightly horizontally - subtract overlap for tighter packing
var xPositions = [];
var currentX = 0;
var overlapAmount = 8; // Slightly reduced overlap for looser horizontal packing
for (var col = 0; col < ballsPerRow; col++) {
if (col === 0) {
// First ball starts at its radius
currentX = ballsData[col].radius;
} else {
// Each subsequent ball is positioned closer than touching distance
var prevRadius = ballsData[col - 1].radius;
var currentRadius = ballsData[col].radius;
currentX += prevRadius + currentRadius - overlapAmount; // Reduce spacing for tighter packing
}
xPositions.push(currentX);
}
// Center the entire row
var totalRowWidth = xPositions[xPositions.length - 1] + ballsData[ballsPerRow - 1].radius;
var offsetX = (localWidth - totalRowWidth) / 2;
// Create and position the balls
for (var col = 0; col < ballsPerRow; col++) {
var initialBall = new Ball(ballsData[col].value);
initialBall.x = xPositions[col] + offsetX;
initialBall.velocityX = 0;
initialBall.velocityY = 0;
initialBall.isStatic = true; // Start as static for stable stacking
rowBalls.push(initialBall);
balls.push(initialBall);
gameArea.addChild(initialBall);
}
// Calculate Y position to make balls touch tightly vertically with previous row
var rowY;
var verticalOverlap = 4; // Slightly reduced vertical overlap for looser packing
if (row === 0) {
// First row sits on bottom
rowY = gameAreaHeight - 100 - maxRadiusInRow;
} else {
// For subsequent rows, find the minimum Y position with tighter packing
rowY = gameAreaHeight; // Start with max possible Y
for (var currentCol = 0; currentCol < rowBalls.length; currentCol++) {
var currentBall = rowBalls[currentCol];
var currentBallRadius = currentBall.radius;
var minYForThisBall = gameAreaHeight - 100 - currentBallRadius; // Default to bottom
// Check against all balls in previous row
for (var prevCol = 0; prevCol < previousRowBalls.length; prevCol++) {
var prevBall = previousRowBalls[prevCol];
var dx = currentBall.x - prevBall.x;
var dy = 0; // We'll calculate the required dy
var horizontalDistance = Math.abs(dx);
var requiredCenterDistance = currentBallRadius + prevBall.radius - verticalOverlap; // Tighter vertical packing
// If balls are close enough horizontally to potentially touch
if (horizontalDistance < requiredCenterDistance) {
// Calculate the vertical distance needed for tighter packing
var verticalDistance = Math.sqrt(Math.max(0, requiredCenterDistance * requiredCenterDistance - dx * dx));
var requiredY = prevBall.y - verticalDistance;
minYForThisBall = Math.min(minYForThisBall, requiredY);
}
}
// The row Y is determined by the ball that needs to be highest (smallest Y)
rowY = Math.min(rowY, minYForThisBall);
}
}
// Apply the calculated Y position to all balls in this row
for (var j = 0; j < rowBalls.length; j++) {
rowBalls[j].y = rowY;
}
// Store this row's balls for next row calculation
previousRowBalls = rowBalls.slice(); // Copy the array
}
}
// Initialize the starting balls
createInitialBalls();
game.down = function (x, y, obj) {
if (dropCooldown > 0) return;
// Activate scoring system when first ball is dropped
if (!scoringActive) {
scoringActive = true;
}
// Constrain drop position to game area (convert to local coordinates)
var localWidth = gameAreaRight - gameAreaLeft;
var localX = x - gameAreaLeft;
var dropX = Math.max(80, Math.min(localWidth - 80, localX));
var newBall = new Ball(nextBallValue);
newBall.x = dropX;
newBall.y = 420; // Start below the danger zone (350px) plus ball radius (60px) plus buffer
newBall.velocityX = 0;
newBall.velocityY = 0;
balls.push(newBall);
gameArea.addChild(newBall);
LK.getSound('drop').play();
// Update next ball
nextBallValue = getRandomNextBallValue();
updateNextBallPreview();
dropCooldown = 30; // 0.5 seconds at 60fps
};
game.update = function () {
if (dropCooldown > 0) {
dropCooldown--;
}
// Handle danger zone immunity countdown
if (dangerZoneImmunity > 0) {
dangerZoneImmunity--;
}
cleanupMergedBalls();
checkGameOver();
// Update score display
scoreText.setText(LK.getScore());
};
Koyu yeşil bilye. In-Game asset. 2d. High contrast. No shadows
Kahverengi bilye. In-Game asset. 2d. High contrast. No shadows
Bprdo renk bilye. In-Game asset. 2d. High contrast. No shadows
Açık kahve bilye. In-Game asset. 2d. High contrast. No shadows
Gri bilye. In-Game asset. 2d. High contrast. No shadows