286 lines
8.6 KiB
JavaScript
286 lines
8.6 KiB
JavaScript
/**
|
|
* Terrain, Soil Decals & Carcass Decomposition System
|
|
* Manages meadow grass, dynamic blood decals with plant nutrient absorption,
|
|
* rain cleansing, and decomposing animal carcasses that sprout flower tufts.
|
|
*/
|
|
|
|
export class Carcass {
|
|
constructor(x, y, species, size = 18) {
|
|
this.x = x;
|
|
this.y = y;
|
|
this.species = species; // 'sheep' or 'wolf'
|
|
this.size = size;
|
|
this.decayProgress = 0; // 0.0 (fresh) to 1.0 (fully decomposed into soil)
|
|
this.meatLeft = 1.0;
|
|
this.isDecomposed = false;
|
|
}
|
|
|
|
update(dt, decayMultiplier = 1.0) {
|
|
const baseDecayRate = 1 / 10.0;
|
|
this.decayProgress += dt * baseDecayRate * decayMultiplier;
|
|
|
|
if (this.decayProgress >= 1.0) {
|
|
this.decayProgress = 1.0;
|
|
this.isDecomposed = true;
|
|
}
|
|
}
|
|
|
|
draw(ctx) {
|
|
ctx.save();
|
|
const alpha = Math.max(0.2, 1 - this.decayProgress * 0.7);
|
|
ctx.globalAlpha = alpha;
|
|
|
|
const s = Math.max(6, Math.round(this.size * (1 - this.decayProgress * 0.35)));
|
|
const px = Math.round(this.x - s / 2);
|
|
const py = Math.round(this.y - s / 3);
|
|
|
|
if (this.decayProgress < 0.6) {
|
|
// Flesh stage
|
|
if (this.species === 'sheep') {
|
|
ctx.fillStyle = '#C8CDD0'; // soiled wool
|
|
ctx.fillRect(px, py, s, Math.round(s * 0.65));
|
|
ctx.fillStyle = '#8A1010'; // blood patches on wool
|
|
ctx.fillRect(px + 2, py + 2, Math.max(2, Math.round(s * 0.3)), 3);
|
|
ctx.fillStyle = '#222';
|
|
ctx.fillRect(px - 3, py + 1, 4, 4);
|
|
} else {
|
|
// Wolf carcass
|
|
ctx.fillStyle = '#3E4246';
|
|
ctx.fillRect(px, py, s, Math.round(s * 0.55));
|
|
ctx.fillStyle = '#8A1010';
|
|
ctx.fillRect(px + 4, py + 1, Math.max(2, Math.round(s * 0.25)), 2);
|
|
}
|
|
} else {
|
|
// Skeletal ribs stage
|
|
ctx.fillStyle = '#E5E1D3';
|
|
const boneLen = Math.round(s * 0.75);
|
|
ctx.fillRect(px, py + 2, boneLen, 2);
|
|
for (let r = 2; r < boneLen - 2; r += 3) {
|
|
ctx.fillRect(px + r, py, 1, 6);
|
|
}
|
|
ctx.fillRect(px - 2, py + 1, 3, 3);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
export class TerrainManager {
|
|
constructor(width, height) {
|
|
this.width = width;
|
|
this.height = height;
|
|
this.carcasses = [];
|
|
|
|
// Active dynamic decals (blood pools, soil mulch) that can be absorbed or washed away
|
|
this.decals = [];
|
|
|
|
// Offscreen canvas for static meadow base grass
|
|
this.baseCanvas = document.createElement('canvas');
|
|
this.baseCanvas.width = width;
|
|
this.baseCanvas.height = height;
|
|
this.baseCtx = this.baseCanvas.getContext('2d');
|
|
|
|
this.generateBaseTerrain();
|
|
}
|
|
|
|
generateBaseTerrain() {
|
|
const ctx = this.baseCtx;
|
|
const grad = ctx.createLinearGradient(0, 0, 0, this.height);
|
|
grad.addColorStop(0, '#559437');
|
|
grad.addColorStop(1, '#4A8330');
|
|
ctx.fillStyle = grad;
|
|
ctx.fillRect(0, 0, this.width, this.height);
|
|
|
|
// Subtle grass flecks
|
|
const fleckColors = ['#5E9E3C', '#45792B', '#68A843', '#407028'];
|
|
const totalFlecks = Math.floor((this.width * this.height) / 350);
|
|
|
|
for (let i = 0; i < totalFlecks; i++) {
|
|
const x = Math.floor(Math.random() * this.width);
|
|
const y = Math.floor(Math.random() * this.height);
|
|
const c = fleckColors[Math.floor(Math.random() * fleckColors.length)];
|
|
ctx.fillStyle = c;
|
|
const w = 2 + Math.floor(Math.random() * 2);
|
|
const h = 2 + Math.floor(Math.random() * 3);
|
|
ctx.fillRect(x, y, w, h);
|
|
}
|
|
|
|
// Tiny wild micro-flowers
|
|
const flowerColors = ['#FFF382', '#FFFFFF', '#FFB7C5', '#8FD3FE'];
|
|
for (let i = 0; i < 90; i++) {
|
|
const x = Math.floor(Math.random() * this.width);
|
|
const y = Math.floor(Math.random() * this.height);
|
|
const c = flowerColors[Math.floor(Math.random() * flowerColors.length)];
|
|
ctx.fillStyle = c;
|
|
ctx.fillRect(x, y, 2, 2);
|
|
}
|
|
}
|
|
|
|
reset() {
|
|
this.carcasses = [];
|
|
this.decals = [];
|
|
}
|
|
|
|
resize(newWidth, newHeight) {
|
|
if (newWidth <= 0 || newHeight <= 0) return;
|
|
this.width = newWidth;
|
|
this.height = newHeight;
|
|
|
|
this.baseCanvas.width = newWidth;
|
|
this.baseCanvas.height = newHeight;
|
|
this.generateBaseTerrain();
|
|
|
|
// Clamp decals and carcasses to new meadow boundaries
|
|
for (let i = 0; i < this.carcasses.length; i++) {
|
|
const c = this.carcasses[i];
|
|
c.x = Math.max(15, Math.min(newWidth - 15, c.x));
|
|
c.y = Math.max(15, Math.min(newHeight - 15, c.y));
|
|
}
|
|
for (let i = 0; i < this.decals.length; i++) {
|
|
const d = this.decals[i];
|
|
d.x = Math.max(10, Math.min(newWidth - 10, d.x));
|
|
d.y = Math.max(10, Math.min(newHeight - 10, d.y));
|
|
}
|
|
}
|
|
|
|
addCarcass(x, y, species, size) {
|
|
const carcass = new Carcass(x, y, species, size);
|
|
this.carcasses.push(carcass);
|
|
this.addBloodSplotch(x, y, 7 + Math.random() * 4);
|
|
return carcass;
|
|
}
|
|
|
|
addBloodSplotch(x, y, radius = 5) {
|
|
const r = Math.max(3, Math.round(radius));
|
|
this.decals.push({
|
|
x,
|
|
y,
|
|
radius: r,
|
|
type: 'blood',
|
|
color: 'rgba(145, 14, 14,',
|
|
maxLife: 28, // seconds before natural fade
|
|
life: 28,
|
|
opacity: 0.75,
|
|
transformed: false,
|
|
});
|
|
|
|
// Keep decal count bounded for performance
|
|
if (this.decals.length > 70) {
|
|
this.decals.shift();
|
|
}
|
|
}
|
|
|
|
addFertilizedSoilPatch(x, y, radius = 16) {
|
|
this.decals.push({
|
|
x,
|
|
y,
|
|
radius,
|
|
type: 'soil',
|
|
maxLife: 35,
|
|
life: 35,
|
|
opacity: 0.75,
|
|
transformed: true,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Absorbs/cleanses blood and soil decals around a growing plant node.
|
|
* As plants grow, they consume the nutrients and remove the blood splats!
|
|
*/
|
|
absorbDecalsNear(x, y, absorbRadius = 30) {
|
|
for (let i = this.decals.length - 1; i >= 0; i--) {
|
|
const d = this.decals[i];
|
|
const dist = Math.hypot(d.x - x, d.y - y);
|
|
if (dist <= absorbRadius + d.radius) {
|
|
// Fast fade or instant absorption by the plant
|
|
d.life = Math.min(d.life, 0.4);
|
|
}
|
|
}
|
|
}
|
|
|
|
update(dt, decayMultiplier, rainIntensity, onCarcassDecomposed, onBloodEnriched) {
|
|
// 1. Update carcasses
|
|
for (let i = this.carcasses.length - 1; i >= 0; i--) {
|
|
const c = this.carcasses[i];
|
|
c.update(dt, decayMultiplier);
|
|
|
|
if (c.isDecomposed) {
|
|
this.addFertilizedSoilPatch(c.x, c.y, 16);
|
|
|
|
if (onCarcassDecomposed) {
|
|
onCarcassDecomposed(c.x, c.y);
|
|
}
|
|
|
|
this.carcasses.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// 2. Update decals: age, rain cleansing wash, and soil enrichment
|
|
// Rain actively washes blood away!
|
|
const rainWashFactor = 1.0 + (rainIntensity || 0) * 3.5;
|
|
|
|
for (let i = this.decals.length - 1; i >= 0; i--) {
|
|
const d = this.decals[i];
|
|
d.life -= dt * decayMultiplier * rainWashFactor;
|
|
|
|
// When blood spot reaches maturity, turn into enriched soil and trigger a bloom
|
|
if (d.type === 'blood' && !d.transformed && d.life <= d.maxLife * 0.4) {
|
|
d.transformed = true;
|
|
d.type = 'soil';
|
|
d.radius += 5;
|
|
if (onBloodEnriched && Math.random() < 0.65) {
|
|
onBloodEnriched(d.x, d.y);
|
|
}
|
|
}
|
|
|
|
// Decal has completely faded or washed away
|
|
if (d.life <= 0) {
|
|
this.decals.splice(i, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
draw(ctx) {
|
|
// 1. Base clean meadow
|
|
ctx.drawImage(this.baseCanvas, 0, 0);
|
|
|
|
// 2. Draw dynamic decals (blood and enriched soil) with smooth fading
|
|
for (let i = 0; i < this.decals.length; i++) {
|
|
const d = this.decals[i];
|
|
const alphaNorm = Math.max(0, Math.min(1, d.life / d.maxLife));
|
|
const currentAlpha = d.opacity * alphaNorm;
|
|
if (currentAlpha <= 0.02) continue;
|
|
|
|
ctx.save();
|
|
if (d.type === 'blood') {
|
|
// Blood pool with subtle splatter dots
|
|
ctx.fillStyle = `rgba(145, 14, 14, ${currentAlpha})`;
|
|
ctx.beginPath();
|
|
ctx.arc(d.x, d.y, d.radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
|
|
// Satellites
|
|
ctx.fillStyle = `rgba(110, 8, 8, ${currentAlpha * 0.8})`;
|
|
ctx.fillRect(Math.round(d.x + d.radius * 0.7), Math.round(d.y - d.radius * 0.5), 2, 2);
|
|
ctx.fillRect(Math.round(d.x - d.radius * 0.8), Math.round(d.y + d.radius * 0.6), 2, 2);
|
|
} else {
|
|
// Enriched mulch soil ring
|
|
const grad = ctx.createRadialGradient(d.x, d.y, 2, d.x, d.y, d.radius);
|
|
grad.addColorStop(0, `rgba(68, 45, 26, ${currentAlpha})`);
|
|
grad.addColorStop(0.7, `rgba(84, 128, 48, ${currentAlpha * 0.7})`);
|
|
grad.addColorStop(1, `rgba(84, 128, 48, 0)`);
|
|
|
|
ctx.fillStyle = grad;
|
|
ctx.beginPath();
|
|
ctx.arc(d.x, d.y, d.radius, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
}
|
|
|
|
// 3. Draw carcasses lying on the field
|
|
for (let i = 0; i < this.carcasses.length; i++) {
|
|
this.carcasses[i].draw(ctx);
|
|
}
|
|
}
|
|
}
|