564 lines
16 KiB
JavaScript
564 lines
16 KiB
JavaScript
// Grid System (Day, Night with Graves, and Pool with Water Lanes)
|
|
window.GridSystem = class GridSystem {
|
|
constructor() {
|
|
this.biome = 'day'; // 'day', 'night', 'pool'
|
|
this.cols = 9;
|
|
this.startX = 120;
|
|
this.startY = 108; // Shifted down so zombie HP bars in top row are fully visible under HUD
|
|
this.cellW = 80;
|
|
|
|
this.graves = [];
|
|
this.animTime = 0;
|
|
this.bgCanvas = null;
|
|
this.bgCtx = null;
|
|
this.bgDirty = true;
|
|
this.initBiome('day');
|
|
}
|
|
|
|
initBackgroundCache() {
|
|
if (!this.bgCanvas) {
|
|
if (typeof document !== 'undefined') {
|
|
this.bgCanvas = document.createElement('canvas');
|
|
this.bgCanvas.width = 900;
|
|
this.bgCanvas.height = 600;
|
|
this.bgCtx = this.bgCanvas.getContext('2d');
|
|
}
|
|
}
|
|
}
|
|
|
|
invalidateBackground() {
|
|
this.bgDirty = true;
|
|
}
|
|
|
|
initBiome(biome = 'day') {
|
|
this.biome = biome;
|
|
this.rows = (biome === 'pool' || biome === 'fog') ? 6 : 5;
|
|
this.cellH = (biome === 'pool' || biome === 'fog') ? 80 : 96;
|
|
this.invalidateBackground();
|
|
|
|
this.grid = [];
|
|
for (let r = 0; r < this.rows; r++) {
|
|
this.grid[r] = [];
|
|
for (let c = 0; c < this.cols; c++) {
|
|
this.grid[r][c] = null;
|
|
}
|
|
}
|
|
|
|
this.graves = [];
|
|
if (biome === 'night') {
|
|
this.graves = [
|
|
{ col: 7, row: 1 },
|
|
{ col: 8, row: 3 },
|
|
{ col: 6, row: 4 }
|
|
];
|
|
}
|
|
|
|
this.initLawnmowers();
|
|
}
|
|
|
|
initLawnmowers() {
|
|
this.lawnmowers = [];
|
|
for (let r = 0; r < this.rows; r++) {
|
|
const isWater = (this.biome === 'pool' || this.biome === 'fog') && (r === 2 || r === 3);
|
|
this.lawnmowers.push({
|
|
row: r,
|
|
x: 65,
|
|
y: this.startY + r * this.cellH + this.cellH / 2,
|
|
active: false,
|
|
used: false,
|
|
speed: 650,
|
|
isCleaner: isWater
|
|
});
|
|
}
|
|
}
|
|
|
|
reset() {
|
|
this.initBiome(this.biome);
|
|
}
|
|
|
|
isWaterCell(row) {
|
|
return (this.biome === 'pool' || this.biome === 'fog') && (row === 2 || row === 3);
|
|
}
|
|
|
|
isRoof() {
|
|
return this.biome === 'roof';
|
|
}
|
|
|
|
isFog() {
|
|
return this.biome === 'fog';
|
|
}
|
|
|
|
hasGrave(col, row) {
|
|
return this.graves.some(g => g.col === col && g.row === row);
|
|
}
|
|
|
|
removeGrave(col, row) {
|
|
this.graves = this.graves.filter(g => !(g.col === col && g.row === row));
|
|
}
|
|
|
|
getCellFromCoords(x, y) {
|
|
if (x < this.startX || x >= this.startX + this.cols * this.cellW) return null;
|
|
if (y < this.startY || y >= this.startY + this.rows * this.cellH) return null;
|
|
|
|
const col = Math.floor((x - this.startX) / this.cellW);
|
|
const row = Math.floor((y - this.startY) / this.cellH);
|
|
|
|
if (col >= 0 && col < this.cols && row >= 0 && row < this.rows) {
|
|
return { col, row };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getCellBounds(col, row) {
|
|
return {
|
|
x: this.startX + col * this.cellW,
|
|
y: this.startY + row * this.cellH,
|
|
w: this.cellW,
|
|
h: this.cellH,
|
|
centerX: this.startX + col * this.cellW + this.cellW / 2,
|
|
centerY: this.startY + row * this.cellH + this.cellH / 2
|
|
};
|
|
}
|
|
|
|
getPlant(col, row) {
|
|
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
|
|
return this.grid[row][col];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
setPlant(col, row, plant) {
|
|
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
|
|
this.grid[row][col] = plant;
|
|
}
|
|
}
|
|
|
|
removePlant(col, row) {
|
|
if (row >= 0 && row < this.rows && col >= 0 && col < this.cols) {
|
|
this.grid[row][col] = null;
|
|
}
|
|
}
|
|
|
|
updateLawnmowers(dt, zombies) {
|
|
this.animTime += dt;
|
|
for (const mower of this.lawnmowers) {
|
|
if (mower.used) continue;
|
|
|
|
if (mower.active) {
|
|
mower.x += mower.speed * dt;
|
|
for (const z of zombies) {
|
|
if (z.row === mower.row && Math.abs(z.x - mower.x) < 45 && z.health > 0) {
|
|
z.takeDamage(9999);
|
|
}
|
|
}
|
|
if (mower.x > 960) {
|
|
mower.used = true;
|
|
mower.active = false;
|
|
}
|
|
} else {
|
|
for (const z of zombies) {
|
|
if (z.row === mower.row && z.x <= 115 && z.health > 0) {
|
|
mower.active = true;
|
|
if (window.PvZAudio) window.PvZAudio.playMower();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
get theme() {
|
|
return this._theme || 'classic';
|
|
}
|
|
|
|
set theme(val) {
|
|
if (this._theme !== val) {
|
|
this._theme = val;
|
|
this.invalidateBackground();
|
|
}
|
|
}
|
|
|
|
renderBackgroundCache() {
|
|
this.initBackgroundCache();
|
|
if (!this.bgCtx) return;
|
|
|
|
const ctx = this.bgCtx;
|
|
ctx.clearRect(0, 0, 900, 600);
|
|
|
|
// 1. Draw Static Lawn Tiles or Pool Base
|
|
for (let r = 0; r < this.rows; r++) {
|
|
const isWater = this.isWaterCell(r);
|
|
|
|
for (let c = 0; c < this.cols; c++) {
|
|
const x = this.startX + c * this.cellW;
|
|
const y = this.startY + r * this.cellH;
|
|
const isEven = (r + c) % 2 === 0;
|
|
|
|
if (this.biome === 'roof') {
|
|
// Terracotta Roof Shingles
|
|
ctx.fillStyle = isEven ? '#bf360c' : '#d84315';
|
|
ctx.fillRect(x, y, this.cellW, this.cellH);
|
|
|
|
// Slanted shingle pattern lines
|
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.22)';
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, y + this.cellH * 0.5);
|
|
ctx.lineTo(x + this.cellW, y + this.cellH * 0.5);
|
|
ctx.stroke();
|
|
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.12)';
|
|
ctx.lineWidth = 1;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + 10, y);
|
|
ctx.lineTo(x + 20, y + this.cellH);
|
|
ctx.moveTo(x + 50, y);
|
|
ctx.lineTo(x + 60, y + this.cellH);
|
|
ctx.stroke();
|
|
} else if (isWater) {
|
|
// Pool Water Base
|
|
ctx.fillStyle = isEven ? '#0288d1' : '#039be5';
|
|
ctx.fillRect(x, y, this.cellW, this.cellH);
|
|
} else if (this.theme === 'cyberpunk') {
|
|
// Cyberpunk Grid Tiles
|
|
ctx.fillStyle = isEven ? '#120326' : '#1d063d';
|
|
ctx.fillRect(x, y, this.cellW, this.cellH);
|
|
ctx.strokeStyle = 'rgba(0, 229, 255, 0.25)';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(x, y, this.cellW, this.cellH);
|
|
ctx.fillStyle = 'rgba(233, 30, 99, 0.4)';
|
|
ctx.fillRect(x + this.cellW - 3, y + this.cellH - 3, 3, 3);
|
|
} else if (this.theme === 'winter') {
|
|
// Winter Frost Lawn
|
|
ctx.fillStyle = isEven ? '#e0f7fa' : '#b2ebf2';
|
|
ctx.fillRect(x, y, this.cellW, this.cellH);
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.6)';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(x, y, this.cellW, this.cellH);
|
|
} else if (this.theme === 'retro8bit') {
|
|
// Retro 8-bit Pixel Lawn
|
|
ctx.fillStyle = isEven ? '#15803d' : '#166534';
|
|
ctx.fillRect(x, y, this.cellW, this.cellH);
|
|
ctx.strokeStyle = '#10b981';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.strokeRect(x, y, this.cellW, this.cellH);
|
|
} else if (this.biome === 'night' || this.biome === 'fog') {
|
|
// Dark Night Lawn
|
|
ctx.fillStyle = isEven ? '#1b5e20' : '#144618';
|
|
ctx.fillRect(x, y, this.cellW, this.cellH);
|
|
} else {
|
|
// Day Lawn
|
|
ctx.fillStyle = isEven ? '#4caf50' : '#43a047';
|
|
ctx.fillRect(x, y, this.cellW, this.cellH);
|
|
}
|
|
|
|
ctx.strokeStyle = 'rgba(0, 0, 0, 0.08)';
|
|
ctx.lineWidth = 1;
|
|
ctx.strokeRect(x, y, this.cellW, this.cellH);
|
|
}
|
|
}
|
|
|
|
// 2. Left Porch Deck / Roof Chimney
|
|
if (this.biome === 'roof') {
|
|
this.drawRoofBackground(ctx);
|
|
} else {
|
|
ctx.fillStyle = (this.biome === 'night' || this.biome === 'fog') ? '#4e342e' : '#795548';
|
|
ctx.fillRect(0, this.startY, this.startX, this.rows * this.cellH);
|
|
ctx.fillStyle = '#3e2723';
|
|
ctx.fillRect(this.startX - 6, this.startY, 6, this.rows * this.cellH);
|
|
|
|
// 3. Right Grass Edge
|
|
const rightEdge = this.startX + this.cols * this.cellW;
|
|
ctx.fillStyle = (this.biome === 'night' || this.biome === 'fog') ? '#0d280f' : '#388e3c';
|
|
ctx.fillRect(rightEdge, this.startY, 900 - rightEdge, this.rows * this.cellH);
|
|
}
|
|
|
|
// 2b. Decorative Neighbour Fence along top edge (y: 86..108)
|
|
this.drawNeighbourFence(ctx);
|
|
|
|
this.bgDirty = false;
|
|
}
|
|
|
|
draw(ctx, hoverCell, selectedPlantType) {
|
|
// 1. Offscreen Canvas Cached Static Background (Lawn, Porch/Chimney, Fence, Water Base)
|
|
if (this.bgDirty || !this.bgCanvas) {
|
|
this.renderBackgroundCache();
|
|
}
|
|
if (this.bgCanvas) {
|
|
ctx.drawImage(this.bgCanvas, 0, 0);
|
|
}
|
|
|
|
// 1b. Dynamic Animated Water Ripples (Pool & Fog)
|
|
if (this.biome === 'pool' || this.biome === 'fog') {
|
|
ctx.save();
|
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
|
|
ctx.lineWidth = 1.5;
|
|
for (let r = 2; r <= 3; r++) {
|
|
for (let c = 0; c < this.cols; c++) {
|
|
const x = this.startX + c * this.cellW;
|
|
const y = this.startY + r * this.cellH;
|
|
const waveOff = Math.sin(this.animTime * 3 + c * 0.8 + r) * 3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x + 10, y + 20 + waveOff);
|
|
ctx.quadraticCurveTo(x + 40, y + 15 + waveOff, x + 70, y + 20 + waveOff);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
// 2. Draw Gravestones in Night Mode
|
|
for (const grave of this.graves) {
|
|
const bounds = this.getCellBounds(grave.col, grave.row);
|
|
this.drawGravestone(ctx, bounds.centerX, bounds.centerY);
|
|
}
|
|
|
|
// 3. Hover effect
|
|
if (hoverCell && selectedPlantType) {
|
|
const bounds = this.getCellBounds(hoverCell.col, hoverCell.row);
|
|
const isWater = this.isWaterCell(hoverCell.row);
|
|
const plant = this.getPlant(hoverCell.col, hoverCell.row);
|
|
const isGrave = this.hasGrave(hoverCell.col, hoverCell.row);
|
|
|
|
let canPlace = false;
|
|
if (selectedPlantType === 'shovel') {
|
|
canPlace = !!plant;
|
|
} else if (this.isRoof()) {
|
|
if (selectedPlantType === 'flowerpot') {
|
|
canPlace = !plant;
|
|
} else {
|
|
canPlace = plant && plant.type === 'flowerpot';
|
|
}
|
|
} else if (selectedPlantType === 'gravebuster') {
|
|
canPlace = isGrave && !plant;
|
|
} else if (isGrave) {
|
|
canPlace = false;
|
|
} else if (isWater) {
|
|
canPlace = selectedPlantType === 'lilypad' || (plant && plant.type === 'lilypad');
|
|
} else {
|
|
canPlace = !plant;
|
|
}
|
|
|
|
ctx.fillStyle = canPlace ? 'rgba(255, 255, 255, 0.35)' : 'rgba(244, 67, 54, 0.35)';
|
|
ctx.fillRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
|
ctx.strokeStyle = canPlace ? '#ffffff' : '#f44336';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(bounds.x, bounds.y, bounds.w, bounds.h);
|
|
}
|
|
|
|
// 4. Draw Lawnmowers & Pool Cleaners
|
|
for (const mower of this.lawnmowers) {
|
|
if (mower.used) continue;
|
|
if (mower.isCleaner) {
|
|
this.drawPoolCleaner(ctx, mower.x, mower.y);
|
|
} else {
|
|
this.drawLawnmower(ctx, mower.x, mower.y);
|
|
}
|
|
}
|
|
|
|
// 5. Volumetric Fog Layer
|
|
if (this.biome === 'fog') {
|
|
this.drawFogOverlay(ctx);
|
|
}
|
|
}
|
|
|
|
drawRoofBackground(ctx) {
|
|
// Porch and terracotta roof chimney
|
|
ctx.fillStyle = '#4e342e';
|
|
ctx.fillRect(0, this.startY, this.startX, this.rows * this.cellH);
|
|
ctx.fillStyle = '#8d6e63';
|
|
ctx.fillRect(25, this.startY + 40, 65, 120);
|
|
ctx.strokeStyle = '#3e2723';
|
|
ctx.lineWidth = 2;
|
|
ctx.strokeRect(25, this.startY + 40, 65, 120);
|
|
|
|
// Right roof edge
|
|
const rightEdge = this.startX + this.cols * this.cellW;
|
|
ctx.fillStyle = '#a02700';
|
|
ctx.fillRect(rightEdge, this.startY, 900 - rightEdge, this.rows * this.cellH);
|
|
}
|
|
|
|
drawFogOverlay(ctx) {
|
|
ctx.save();
|
|
for (let c = 4; c < this.cols; c++) {
|
|
const fx = this.startX + c * this.cellW;
|
|
const drift = Math.sin(this.animTime * 1.2 + c) * 8;
|
|
ctx.fillStyle = 'rgba(38, 50, 56, 0.78)';
|
|
ctx.fillRect(fx + drift, this.startY, this.cellW + 12, this.rows * this.cellH);
|
|
}
|
|
for (let i = 0; i < 7; i++) {
|
|
const cx = 500 + i * 55 + Math.sin(this.animTime * 0.9 + i) * 18;
|
|
const cy = this.startY + (i * 70) % (this.rows * this.cellH);
|
|
ctx.fillStyle = 'rgba(69, 90, 100, 0.42)';
|
|
ctx.beginPath();
|
|
ctx.arc(cx, cy, 46, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
drawGravestone(ctx, x, y) {
|
|
ctx.save();
|
|
ctx.translate(x, y + 10);
|
|
|
|
// Stone base shadow
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 24, 26, 8, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Tombstone body
|
|
ctx.fillStyle = '#757575';
|
|
ctx.beginPath();
|
|
ctx.roundRect(-22, -26, 44, 48, [16, 16, 2, 2]);
|
|
ctx.fill();
|
|
ctx.strokeStyle = '#424242';
|
|
ctx.lineWidth = 2;
|
|
ctx.stroke();
|
|
|
|
// RIP text
|
|
ctx.fillStyle = '#212121';
|
|
ctx.font = 'bold 12px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('R.I.P.', 0, -2);
|
|
|
|
// Crack
|
|
ctx.strokeStyle = '#212121';
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-10, 8);
|
|
ctx.lineTo(-4, 14);
|
|
ctx.lineTo(-8, 20);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawLawnmower(ctx, x, y) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
|
|
ctx.fillStyle = '#212121';
|
|
ctx.beginPath();
|
|
ctx.arc(-14, 16, 7, 0, Math.PI * 2);
|
|
ctx.arc(14, 16, 7, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = '#9e9e9e';
|
|
ctx.beginPath();
|
|
ctx.arc(-14, 16, 3, 0, Math.PI * 2);
|
|
ctx.arc(14, 16, 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = '#d32f2f';
|
|
ctx.beginPath();
|
|
ctx.roundRect(-22, -6, 44, 20, [6, 6, 2, 2]);
|
|
ctx.fill();
|
|
|
|
ctx.fillStyle = '#424242';
|
|
ctx.fillRect(-10, -18, 20, 14);
|
|
|
|
ctx.strokeStyle = '#bdbdbd';
|
|
ctx.lineWidth = 3;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-18, 0);
|
|
ctx.lineTo(-32, -26);
|
|
ctx.stroke();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawPoolCleaner(ctx, x, y) {
|
|
ctx.save();
|
|
ctx.translate(x, y);
|
|
|
|
// Blue floating cleaner
|
|
ctx.fillStyle = '#0288d1';
|
|
ctx.beginPath();
|
|
ctx.roundRect(-20, -10, 40, 24, 8);
|
|
ctx.fill();
|
|
|
|
// Yellow propeller
|
|
ctx.fillStyle = '#ffeb3b';
|
|
ctx.beginPath();
|
|
ctx.arc(0, 2, 8, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
ctx.restore();
|
|
}
|
|
|
|
drawNeighbourFence(ctx) {
|
|
if (this.biome === 'roof') return; // Roof has shingles/gutters instead
|
|
|
|
const fenceY = 88;
|
|
const fenceH = 20;
|
|
|
|
ctx.save();
|
|
|
|
// 1. Shadow under fence on the lawn
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.28)';
|
|
ctx.fillRect(0, fenceY + fenceH - 2, 900, 4);
|
|
|
|
// 2. Dirt/Base Strip
|
|
ctx.fillStyle = (this.biome === 'night' || this.biome === 'fog') ? '#1a0f07' : '#3e2723';
|
|
ctx.fillRect(0, fenceY + fenceH - 4, 900, 4);
|
|
|
|
// 3. Horizontal Support Rails
|
|
const isNight = this.biome === 'night' || this.biome === 'fog';
|
|
const isWinter = this.theme === 'winter';
|
|
const isCyber = this.theme === 'cyberpunk';
|
|
|
|
let railColor = isNight ? '#4e342e' : '#8d6e63';
|
|
let picketColor = isNight ? '#5d4037' : '#d7ccc8';
|
|
let picketTopColor = isNight ? '#795548' : '#efebe9';
|
|
|
|
if (isWinter) {
|
|
picketColor = '#eceff1';
|
|
picketTopColor = '#ffffff';
|
|
railColor = '#b0bec5';
|
|
} else if (isCyber) {
|
|
picketColor = '#21083a';
|
|
picketTopColor = '#00e5ff';
|
|
railColor = '#e91e63';
|
|
}
|
|
|
|
// Top & Bottom Horizontal Rails
|
|
ctx.fillStyle = railColor;
|
|
ctx.fillRect(0, fenceY + 4, 900, 3);
|
|
ctx.fillRect(0, fenceY + 12, 900, 3);
|
|
|
|
// 4. Vertical Pickets (Wooden Fence Slats)
|
|
const picketWidth = 8;
|
|
const picketGap = 16;
|
|
for (let x = 4; x < 900; x += picketGap) {
|
|
// Picket Body
|
|
ctx.fillStyle = picketColor;
|
|
ctx.fillRect(x, fenceY + 2, picketWidth, fenceH - 4);
|
|
|
|
// Pointy Picket Cap
|
|
ctx.fillStyle = picketTopColor;
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, fenceY + 2);
|
|
ctx.lineTo(x + picketWidth / 2, fenceY - 2);
|
|
ctx.lineTo(x + picketWidth, fenceY + 2);
|
|
ctx.closePath();
|
|
ctx.fill();
|
|
|
|
// Nail / Wood detail
|
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.4)';
|
|
ctx.fillRect(x + 3, fenceY + 5, 2, 2);
|
|
ctx.fillRect(x + 3, fenceY + 13, 2, 2);
|
|
|
|
// Winter snow cap
|
|
if (isWinter) {
|
|
ctx.fillStyle = '#ffffff';
|
|
ctx.beginPath();
|
|
ctx.arc(x + picketWidth / 2, fenceY - 2, 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
};
|