项目文件夹

文件
Christina Pan af5af0c445 Add score decay over time feature
Implemented score decay mechanics where cells gradually lose score over time.
Larger cells decay faster based on their size, adding a strategic element to gameplay.
Added comprehensive unit tests for the feature.
2025-05-27 14:43:37 -07:00

340 行
11 KiB
JavaScript

import { gameState, mouse } from './gameState.js';
import { getSize, getRandomPosition, calculateCenterOfMass, getDistance } from './utils.js';
import {
WORLD_SIZE,
FOOD_COUNT,
AI_COUNT,
MIN_SPLIT_SCORE,
SPLIT_VELOCITY,
MAX_PLAYER_CELLS,
AI_STARTING_SCORE,
MERGE_COOLDOWN,
MERGE_DISTANCE,
MERGE_FORCE,
MERGE_START_FORCE,
BASE_DECAY_RATE,
DECAY_SCALE_FACTOR
} from './config.js';
const AI_NAMES = [
'Cursor',
'Zed',
'VSCode',
'Visual Studio',
'Eclipse',
'JetBrains',
'XCode',
'Sublime',
'Neovim',
'Emacs'
];
// Function to get an unused AI name
function getUnusedAIName() {
const usedNames = new Set(gameState.aiPlayers.map(ai => ai.name));
return AI_NAMES.find(name => !usedNames.has(name)) || AI_NAMES[0];
}
function updateCellMerging() {
const now = Date.now();
const cellsToMerge = [];
// First pass: calculate merging forces and identify mergeable cells
for (let i = 0; i < gameState.playerCells.length; i++) {
const cell1 = gameState.playerCells[i];
// Skip if cell is already marked for merging
if (cellsToMerge.includes(i)) continue;
for (let j = i + 1; j < gameState.playerCells.length; j++) {
const cell2 = gameState.playerCells[j];
// Skip if cell is already marked for merging
if (cellsToMerge.includes(j)) continue;
const distance = getDistance(cell1, cell2);
const cell1Size = getSize(cell1.score);
const cell2Size = getSize(cell2.score);
const minMergeDistance = (cell1Size + cell2Size) * MERGE_DISTANCE;
const minDistance = cell1Size + cell2Size; // Minimum distance before repulsion
// Calculate time since split
const timeSinceSplit1 = now - (cell1.splitTime || 0);
const timeSinceSplit2 = now - (cell2.splitTime || 0);
const canMerge = timeSinceSplit1 > MERGE_COOLDOWN && timeSinceSplit2 > MERGE_COOLDOWN;
if (distance < minMergeDistance && canMerge) {
// Mark cells for merging only if they're very close
if (distance < minDistance * 0.5) {
cellsToMerge.push(i, j);
} else {
// Strong attraction force when close to merging
const dx = cell2.x - cell1.x;
const dy = cell2.y - cell1.y;
const force = MERGE_FORCE;
const factor = force / Math.max(1, distance);
cell1.velocityX += dx * factor;
cell1.velocityY += dy * factor;
cell2.velocityX -= dx * factor;
cell2.velocityY -= dy * factor;
}
} else {
// Calculate repulsion when too close
if (distance < minDistance) {
const repulsionStrength = 0.3; // Adjust this to control repulsion strength
const repulsionFactor = (minDistance - distance) / minDistance * repulsionStrength;
const dx = cell2.x - cell1.x;
const dy = cell2.y - cell1.y;
// Apply repulsion
cell1.velocityX -= dx * repulsionFactor;
cell1.velocityY -= dy * repulsionFactor;
cell2.velocityX += dx * repulsionFactor;
cell2.velocityY += dy * repulsionFactor;
}
// Apply attraction force if not too close
if (distance > minDistance) {
const dx = cell2.x - cell1.x;
const dy = cell2.y - cell1.y;
const force = canMerge ? MERGE_FORCE : MERGE_START_FORCE;
const factor = force / Math.max(1, distance);
cell1.velocityX += dx * factor;
cell1.velocityY += dy * factor;
cell2.velocityX -= dx * factor;
cell2.velocityY -= dy * factor;
}
}
}
}
// Second pass: merge cells
if (cellsToMerge.length > 0) {
// Sort indices in descending order to remove from end first
cellsToMerge.sort((a, b) => b - a);
// Get unique indices
const uniqueIndices = [...new Set(cellsToMerge)];
// Group cells to merge
const groups = [];
let currentGroup = [uniqueIndices[0]];
for (let i = 1; i < uniqueIndices.length; i++) {
const current = uniqueIndices[i];
const prev = currentGroup[currentGroup.length - 1];
if (prev - current === 1) {
currentGroup.push(current);
} else {
groups.push(currentGroup);
currentGroup = [current];
}
}
groups.push(currentGroup);
// Merge each group
groups.forEach(group => {
const cells = group.map(index => gameState.playerCells[index]);
// Calculate total score and weighted position
const totalScore = cells.reduce((sum, cell) => sum + cell.score, 0);
const weightedX = cells.reduce((sum, cell) => sum + cell.x * cell.score, 0) / totalScore;
const weightedY = cells.reduce((sum, cell) => sum + cell.y * cell.score, 0) / totalScore;
// Calculate average velocity weighted by mass
const avgVelocityX = cells.reduce((sum, cell) => sum + cell.velocityX * cell.score, 0) / totalScore;
const avgVelocityY = cells.reduce((sum, cell) => sum + cell.velocityY * cell.score, 0) / totalScore;
// Remove old cells (in reverse order to maintain correct indices)
group.sort((a, b) => b - a).forEach(index => {
gameState.playerCells.splice(index, 1);
});
// Add merged cell with combined score
gameState.playerCells.push({
x: weightedX,
y: weightedY,
score: totalScore, // This is the sum of all merged cell scores
velocityX: avgVelocityX,
velocityY: avgVelocityY,
splitTime: 0 // Reset split time for merged cell
});
});
}
}
let lastUpdateTime = Date.now();
export function updatePlayer() {
const currentTime = Date.now();
const deltaTime = (currentTime - lastUpdateTime) / 1000; // Convert to seconds
lastUpdateTime = currentTime;
const dx = mouse.x - window.innerWidth / 2;
const dy = mouse.y - window.innerHeight / 2;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
const direction = {
x: dx / distance,
y: dy / distance
};
// Update each cell
gameState.playerCells.forEach(cell => {
// Base speed is inversely proportional to cell size
const speed = 5 / (getSize(cell.score) / 20);
// Update velocity (with inertia)
cell.velocityX = cell.velocityX * 0.9 + direction.x * speed * 0.1;
cell.velocityY = cell.velocityY * 0.9 + direction.y * speed * 0.1;
// Update position
cell.x = Math.max(0, Math.min(WORLD_SIZE, cell.x + cell.velocityX));
cell.y = Math.max(0, Math.min(WORLD_SIZE, cell.y + cell.velocityY));
// Apply score decay
applyScoreDecay(cell, deltaTime);
});
}
// Handle cell merging
updateCellMerging();
}
export function splitPlayerCell(cell) {
if (cell.score < MIN_SPLIT_SCORE ||
gameState.playerCells.length >= MAX_PLAYER_CELLS) {
return;
}
// Calculate split direction (towards mouse)
const dx = mouse.x - window.innerWidth / 2;
const dy = mouse.y - window.innerHeight / 2;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance === 0) return;
const direction = {
x: dx / distance,
y: dy / distance
};
const now = Date.now();
// Create new cell
const newCell = {
x: cell.x,
y: cell.y,
score: cell.score / 2,
velocityX: direction.x * SPLIT_VELOCITY,
velocityY: direction.y * SPLIT_VELOCITY,
splitTime: now
};
// Update original cell
cell.score /= 2;
cell.velocityX = -direction.x * SPLIT_VELOCITY * 0.5;
cell.velocityY = -direction.y * SPLIT_VELOCITY * 0.5;
cell.splitTime = now;
// Add new cell
gameState.playerCells.push(newCell);
}
export function handlePlayerSplit() {
// Split each cell that's large enough
const cellsToSplit = gameState.playerCells.filter(cell =>
cell.score >= MIN_SPLIT_SCORE &&
gameState.playerCells.length < MAX_PLAYER_CELLS
);
cellsToSplit.forEach(cell => splitPlayerCell(cell));
}
function applyScoreDecay(entity, deltaTime) {
// Calculate decay based on size (larger entities decay faster)
const size = getSize(entity.score);
const decayRate = BASE_DECAY_RATE * (1 + size * DECAY_SCALE_FACTOR);
// Apply decay based on time elapsed
const decay = decayRate * deltaTime;
entity.score = Math.max(MIN_SPLIT_SCORE / 2, entity.score - decay);
}
export function updateAI() {
const currentTime = Date.now();
const deltaTime = (currentTime - lastUpdateTime) / 1000; // Convert to seconds
gameState.aiPlayers.forEach(ai => {
if (Math.random() < 0.02) {
ai.direction = Math.random() * Math.PI * 2;
}
const speed = 5 / (getSize(ai.score) / 20);
ai.x += Math.cos(ai.direction) * speed;
ai.y += Math.sin(ai.direction) * speed;
ai.x = Math.max(0, Math.min(WORLD_SIZE, ai.x));
ai.y = Math.max(0, Math.min(WORLD_SIZE, ai.y));
// Apply score decay
applyScoreDecay(ai, deltaTime);
});
}
export function initEntities() {
// Clear existing entities
gameState.food = [];
gameState.aiPlayers = [];
console.log('Initializing entities...');
// Initialize food
for (let i = 0; i < FOOD_COUNT; i++) {
const pos = getRandomPosition();
gameState.food.push({
x: pos.x,
y: pos.y,
color: `hsl(${Math.random() * 360}, 50%, 50%)`
});
}
// Initialize AI players
for (let i = 0; i < AI_COUNT; i++) {
const pos = getRandomPosition();
const ai = {
x: pos.x,
y: pos.y,
score: AI_STARTING_SCORE,
color: `hsl(${Math.random() * 360}, 70%, 50%)`,
direction: Math.random() * Math.PI * 2,
name: getUnusedAIName()
};
gameState.aiPlayers.push(ai);
}
console.log('Entities initialized:', {
foodCount: gameState.food.length,
aiCount: gameState.aiPlayers.length,
playerCells: gameState.playerCells.length
});
}
// Export for use in other modules
export function respawnAI() {
const pos = getRandomPosition();
const name = getUnusedAIName();
return {
x: pos.x,
y: pos.y,
score: AI_STARTING_SCORE,
color: `hsl(${Math.random() * 360}, 70%, 50%)`,
direction: Math.random() * Math.PI * 2,
name: name
};
}