diff --git a/assets/images/gootower/gear.png b/assets/images/gootower/gear.png new file mode 100644 index 0000000..7456923 Binary files /dev/null and b/assets/images/gootower/gear.png differ diff --git a/assets/images/gootower/pipe.png b/assets/images/gootower/pipe.png new file mode 100644 index 0000000..eb59a10 Binary files /dev/null and b/assets/images/gootower/pipe.png differ diff --git a/src/data/assetManifest.js b/src/data/assetManifest.js index 750919e..04ce569 100644 --- a/src/data/assetManifest.js +++ b/src/data/assetManifest.js @@ -63,6 +63,8 @@ const image = (key, path) => ({ type: 'image', key, path }); export const MANIFEST = { gootower: [ image('gootower-bg', 'assets/images/gootower/background-1.png'), + image('gootower-pipe', 'assets/images/gootower/pipe.png'), + image('gootower-gear', 'assets/images/gootower/gear.png'), ], // Excitebike draws every pixel itself (see ExcitebikeRaster.js), so there is // no art to fetch — only its soundtrack. diff --git a/src/games/gootower/GooTowerGame.js b/src/games/gootower/GooTowerGame.js index a5ffeda..1bfe239 100644 --- a/src/games/gootower/GooTowerGame.js +++ b/src/games/gootower/GooTowerGame.js @@ -14,8 +14,17 @@ import { const BG = 0x121a24; const SKY_TOP = 0x1b2a3a; const SKY_BOT = 0x0d1219; -const DIRT = 0x2f2a3d; -const DIRT_RIM = 0x4a4260; +const DIRT = 0x9c7a52; // lighter, warmer dirt fill +const DIRT_RIM = 0x5c4327; // thick accent stroke on sides/bottom +const DIRT_STROKE_W = 9; +const DIRT_FLECK_DARK = 0x7a5c3c; +const DIRT_FLECK_LIGHT = 0xb99268; +const DIRT_TEX_STEP = 22; // grid spacing for the dirt-fleck texture pass +const GRASS = 0x5fae3b; +const GRASS_DARK = 0x3f7d28; +const GRASS_INSET = DIRT_STROKE_W; // keeps the whole accent stroke visible; grass starts past it +const GRASS_DEPTH = 15; // how far the patchy band reaches down into the dirt, beyond the inset +const GRASS_SEG = 20; // approx width of one grass patch, in px const SPIKE = 0x7d3550; const SPIKE_RIM = 0xd46a8c; const FIRE = 0xd4642a; @@ -83,6 +92,53 @@ const lerpColor = (a, b, t) => { return (ch(16) << 16) | (ch(8) << 8) | ch(0); }; +// Deterministic spatial hash -> [0,1). Used for grass-patch variation so a +// terrain piece looks the same on every redraw (e.g. after a blast opens a +// destructible rock) instead of re-rolling every frame. +const hash01 = (n) => { + const s = Math.sin(n * 12.9898) * 43758.5453; + return s - Math.floor(s); +}; + +// Splits a terrain polygon into edges tagged with their true outward normal +// (found via the centroid, so it works regardless of winding order) and +// whether that edge faces up -- i.e. is exposed ground a grass patch belongs +// on, as opposed to a side or the underside. +function topFacingEdges(poly) { + const n = poly.length; + let cx = 0, cy = 0; + for (const [x, y] of poly) { cx += x; cy += y; } + cx /= n; cy /= n; + const edges = []; + for (let i = 0; i < n; i += 1) { + const [ax, ay] = poly[i]; + const [bx, by] = poly[(i + 1) % n]; + const ex = bx - ax, ey = by - ay; + const len = Math.hypot(ex, ey) || 1; + let nx = -ey / len, ny = ex / len; + const mx = (ax + bx) / 2, my = (ay + by) / 2; + if ((mx - cx) * nx + (my - cy) * ny < 0) { nx = -nx; ny = -ny; } + edges.push({ ax, ay, bx, by, nx, ny, top: ny < -0.5 }); + } + return edges; +} + +// Standard ray-cast point-in-polygon test, used to clip the dirt-fleck +// texture to a terrain piece's actual shape rather than its bounding box -- +// most pieces are simple rectangles/trapezoids, but this keeps flecks from +// spilling past the edge on the handful that aren't. +function pointInPoly(px, py, poly) { + let inside = false; + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const [xi, yi] = poly[i]; + const [xj, yj] = poly[j]; + const cross = ((yi > py) !== (yj > py)) && + (px < ((xj - xi) * (py - yi)) / (yj - yi) + xi); + if (cross) inside = !inside; + } + return inside; +} + // Vertical extent of a level's actual content (terrain, pipe, balls) — used to // stop the camera ever showing empty space below the floor or above the // topmost element, rather than the old fixed-world-height slack. Computed @@ -139,6 +195,9 @@ export default class GooTowerGame extends Phaser.Scene { this.levelBounds = null; // { top, bottom }, set per level in startLevel() this.bgImage = null; this.suckFX = []; // short-lived cosmetic tails for balls the pipe just collected + this.pipeImg = null; // set in setupPipeSprite() when gootower-pipe art is loaded + this.pipeGlow = null; // graphics layer painted OVER pipeImg for the open/close pulse + this.gearSprites = []; // [{ gear, img }], set in setupGearSprites() } async create() { @@ -214,6 +273,9 @@ export default class GooTowerGame extends Phaser.Scene { this.board = null; this.panFrom = null; this.suckFX = []; + this.pipeImg = null; + this.pipeGlow = null; + this.gearSprites = []; this.needText = null; this.hudText = null; if (this.introAnim) { this.tweens.killTweensOf(this.introAnim); this.introAnim = null; } @@ -387,12 +449,24 @@ export default class GooTowerGame extends Phaser.Scene { this.finished = false; // One container for the whole board; its transform is the view. Children - // are added in paint order because containers ignore child depth. + // are added in paint order because containers ignore child depth. Gear + // art sits between terrain and dyn so it paints under strands/balls, same + // as the procedural drawGears() call used to (drawn first inside dyn); + // pipe art sits after dyn so it still paints over balls/suck-tendrils, + // same as the old drawPipe() call being last inside dyn. this.board = this.track(this.add.container(0, 0).setDepth(D.terrain)); this.terrainG = this.add.graphics(); + this.board.add(this.terrainG); + this.setupGearSprites(); this.dyn = this.add.graphics(); + this.board.add(this.dyn); + this.setupPipeSprite(); + // Painted after pipeImg so the open/close pulse (drawPipeGlow) shows over + // the opaque sprite instead of being covered by it. + this.pipeGlow = this.add.graphics(); + this.board.add(this.pipeGlow); this.ghost = this.add.graphics(); - this.board.add([this.terrainG, this.dyn, this.ghost]); + this.board.add(this.ghost); this.view = { ...VIEW_HOME }; this.applyView(); @@ -402,6 +476,39 @@ export default class GooTowerGame extends Phaser.Scene { this.startIntroAnim(); } + // One Image per gear, in place of the old drawGears() procedural redraw. + // The gear's polygon still gets rebuilt every physics tick in + // GooTowerLogic.js (collision needs it) -- this only replaces the RENDER + // side, rotating a static sprite instead of re-triangulating teeth each + // frame. Falls back to the procedural draw when no art is loaded. + setupGearSprites() { + this.gearSprites = []; + if (!this.textures.exists('gootower-gear')) return; + for (const t of this.state.terrain) { + if (!t.gear) continue; + const img = this.add.image(t.gear.cx, t.gear.cy, 'gootower-gear'); + img.setDisplaySize(t.gear.r * 2, t.gear.r * 2); + img.setRotation(t.gear.angle); + this.board.add(img); + this.gearSprites.push({ gear: t.gear, img }); + } + } + + // Static pipe art in place of the old drawPipe() procedural body+opening. + // Anchored bottom-center (setOrigin(0.5, 1)) because that's where the + // procedural version's (x, y) sat -- the body extended upward from there. + // The open/closed pulse is still drawn procedurally each frame (see + // drawPipeGlow) since state.pipe.open can flip either way mid-level. + setupPipeSprite() { + this.pipeImg = null; + const p = this.state.pipe; + if (!p || !this.textures.exists('gootower-pipe')) return; + const img = this.add.image(p.x, p.y, 'gootower-pipe').setOrigin(0.5, 1); + img.setDisplaySize(p.r * 1.7, p.r * 1.5); + this.board.add(img); + this.pipeImg = img; + } + // Redrawn on demand, not just once: a blast can delete destructible terrain. drawTerrain() { const g = this.terrainG; @@ -413,8 +520,15 @@ export default class GooTowerGame extends Phaser.Scene { if (t.kind === 'solid') { g.fillStyle(DIRT, 1); g.fillPoints(pts, true); - g.lineStyle(4, DIRT_RIM, 1); + this.drawDirtTexture(g, t); + // Thick stroke stays fully visible on every edge, including the top; + // grass is inset past it (see GRASS_INSET) rather than painted over + // it, so the accented outline reads as a border under the turf. + g.lineStyle(DIRT_STROKE_W, DIRT_RIM, 1); g.strokePoints(pts, true); + for (const e of topFacingEdges(t.poly)) { + if (e.top) this.drawGrassEdge(g, e); + } } else if (t.kind === 'spike') { g.fillStyle(SPIKE, 1); g.fillPoints(pts, true); @@ -435,6 +549,60 @@ export default class GooTowerGame extends Phaser.Scene { this.drawFans(g); } + // A patchy strip of grass along one top-facing edge of a dirt polygon: + // short segments of varying depth and two-tone green, with occasional bare + // gaps, so it reads as uneven turf rather than a painted racing stripe. + // The whole band starts GRASS_INSET past the true edge so the full accent + // stroke stays visible as a border between the open air and the turf. + drawGrassEdge(g, e) { + const dx = e.bx - e.ax, dy = e.by - e.ay; + const len = Math.hypot(dx, dy); + if (len < 1) return; + const inX = -e.nx, inY = -e.ny; // inward, i.e. down into the dirt + // Inset start point: the band runs parallel to the true edge, offset + // inward by GRASS_INSET, for its full original length (dx, dy). + const bax = e.ax + inX * GRASS_INSET, bay = e.ay + inY * GRASS_INSET; + const seed = e.ax * 0.131 + e.ay * 0.277 + e.bx * 0.071 + e.by * 0.193; + const nSeg = Math.max(1, Math.round(len / GRASS_SEG)); + for (let i = 0; i < nSeg; i += 1) { + const s = seed + i * 7.919; + if (hash01(s) < 0.12) continue; // bare dirt gap -- keeps the band patchy + const t0 = i / nSeg, t1 = (i + 1) / nSeg; + const d0 = GRASS_DEPTH * (0.5 + 0.6 * hash01(s + 1.7)); + const d1 = GRASS_DEPTH * (0.5 + 0.6 * hash01(s + 4.4)); + const ax0 = bax + dx * t0, ay0 = bay + dy * t0; + const ax1 = bax + dx * t1, ay1 = bay + dy * t1; + g.fillStyle(hash01(s + 2.6) < 0.5 ? GRASS : GRASS_DARK, 1); + g.fillPoints([ + { x: ax0, y: ay0 }, + { x: ax1, y: ay1 }, + { x: ax1 + inX * d1, y: ay1 + inY * d1 }, + { x: ax0 + inX * d0, y: ay0 + inY * d0 }, + ], true); + } + } + + // Subtle mottled speckling across the dirt fill, clipped to the polygon's + // actual shape (not just its bounding box) so it doesn't spill past + // non-rectangular edges. Purely a texture pass -- deterministic per piece + // so it doesn't shimmer on redraw (e.g. when a blast opens a neighbor). + drawDirtTexture(g, t) { + const bb = t.bounds; + for (let gx = bb.minX; gx < bb.maxX; gx += DIRT_TEX_STEP) { + for (let gy = bb.minY; gy < bb.maxY; gy += DIRT_TEX_STEP) { + const cellSeed = gx * 0.041 + gy * 0.077; + if (hash01(cellSeed) < 0.55) continue; // sparse -- a hint of texture, not noise + const jx = gx + (hash01(cellSeed + 1.1) - 0.5) * DIRT_TEX_STEP * 0.8; + const jy = gy + (hash01(cellSeed + 2.3) - 0.5) * DIRT_TEX_STEP * 0.8; + if (!pointInPoly(jx, jy, t.poly)) continue; + const dark = hash01(cellSeed + 3.7) < 0.5; + const rad = 2 + hash01(cellSeed + 4.9) * 2.5; + g.fillStyle(dark ? DIRT_FLECK_DARK : DIRT_FLECK_LIGHT, dark ? 0.35 : 0.28); + g.fillCircle(jx, jy, rad); + } + } + } + drawFans(g) { for (const f of this.state.fans) { g.fillStyle(0x8fd0e8, 0.07); @@ -747,7 +915,11 @@ export default class GooTowerGame extends Phaser.Scene { if (!g || !st) return; g.clear(); - this.drawGears(g); + if (this.gearSprites.length) { + for (const gs of this.gearSprites) gs.img.setRotation(gs.gear.angle); + } else { + this.drawGears(g); + } // Strands first, under the balls — drawn as pinched goo shapes (thick // at the ends, thin in the middle with curved edges). @@ -821,7 +993,13 @@ export default class GooTowerGame extends Phaser.Scene { } this.drawSuckFX(g); - this.drawPipe(g); + if (this.pipeImg) { + this.pipeImg.setTint(this.state.pipe.open ? 0xffffff : 0x9a9a9a); + this.pipeGlow.clear(); + this.drawPipeGlow(this.pipeGlow); + } else { + this.drawPipe(g); + } this.drawGhost(); } @@ -990,6 +1168,19 @@ export default class GooTowerGame extends Phaser.Scene { } } + // Just the pulsing "open" ring, used over pipe art (see setupPipeSprite) + // instead of the full procedural body+opening drawPipe() draws. Kept + // separate from the sprite because state.pipe.open can flip either way + // mid-level (a structure can lose its connection to the pipe), so this + // still needs a per-frame check rather than a one-time sprite swap. + drawPipeGlow(g) { + const p = this.state.pipe; + if (!p || !p.open) return; + const pulse = 0.35 + 0.25 * Math.sin(this.state.clock * 6); + g.lineStyle(3, PIPE_RIM, pulse); + g.strokeCircle(p.x, p.y - p.r * 0.35, p.r * (1.0 + 0.12 * Math.sin(this.state.clock * 4))); + } + // Live feedback while dragging: exactly which strands would form, and // whether the drop is legal at all. Without this the MIN_ANGLE rule is // invisible and the player just feels rejected at random.