feat(gootower): replace procedural gear/pipe with sprite art and add terrain textures
- Add gear.png and pipe.png sprite assets for GooTower - Replace procedural drawGears() with rotated sprite instances for better performance - Replace procedural drawPipe() with sprite art and separate procedural glow overlay - Add patchy grass strips along top-facing terrain edges with variable depth and two-tone coloring - Add mottled dirt texture pass clipped to polygon bounds - Introduce helper functions (hash01, topFacingEdges, pointInPoly) for texture rendering - Update asset manifest to include new gootower images
This commit is contained in:
parent
4dab1ac7d5
commit
221f1d428a
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 609 KiB |
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue