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