master
Denis Ranneft 1 month ago
parent d015f68d14
commit da21c4cbb0

@ -615,7 +615,7 @@ function tweakVisualForSlug(base: EnemyVisualConfig, slug: string): EnemyVisualC
const h2 = (Math.imul(h, 0x9e3779b1) >>> 0) ^ slug.length; const h2 = (Math.imul(h, 0x9e3779b1) >>> 0) ^ slug.length;
const bodyShape = BODY_SHAPE_ORDER[Math.abs(h) % BODY_SHAPE_ORDER.length] ?? base.bodyShape; const bodyShape = BODY_SHAPE_ORDER[Math.abs(h) % BODY_SHAPE_ORDER.length] ?? base.bodyShape;
const headShape = HEAD_SHAPE_ORDER[Math.abs(h2) % HEAD_SHAPE_ORDER.length] ?? base.headShape; const headShape = HEAD_SHAPE_ORDER[Math.abs(h2) % HEAD_SHAPE_ORDER.length] ?? base.headShape;
const sizeMul = 0.86 + ((h ^ h2) % 29) / 100; const sizeMul = 1.5 + ((h ^ h2) % 29) / 100;
return { return {
...base, ...base,
bodyShape, bodyShape,

@ -42,8 +42,18 @@ type SpritePoolEntry = {
textureKey: string; textureKey: string;
worldX?: number; worldX?: number;
worldY?: number; worldY?: number;
/** Set when sprite is hidden by pooling; cleared when shown again. */
hiddenSinceMs?: number;
}; };
const SPRITE_HIDDEN_EVICT_MS = 3000;
/**
* Max width×height (local px) for baking Graphics into a texture via {@link Graphics#cacheAsTexture}.
* Procedural ground fills the whole visible tile grid caching would allocate a huge RenderTexture.
*/
const MAX_STATIC_GRAPHICS_CACHE_AREA = 2_500_000;
const TERRAIN_SEAM_BLEED_SCALE = 1.002; const TERRAIN_SEAM_BLEED_SCALE = 1.002;
/** Convert world (tile) coordinates to screen (pixel) coordinates */ /** Convert world (tile) coordinates to screen (pixel) coordinates */
@ -129,7 +139,7 @@ export class GameRenderer {
| null = null; | null = null;
private _lastSpritesReady = false; private _lastSpritesReady = false;
// Reusable Graphics objects (avoid GC in hot path) // Reusable Graphics (avoid GC). Animated / full-screen paths are not texture-cached — see drawTowns / drawGround.
private _groundGfx: Graphics | null = null; private _groundGfx: Graphics | null = null;
private _heroGfx: Graphics | null = null; private _heroGfx: Graphics | null = null;
/** Second adventurer silhouette during hero_meet (opponent at server stand position). */ /** Second adventurer silhouette during hero_meet (opponent at server stand position). */
@ -372,12 +382,71 @@ export class GameRenderer {
return entry; return entry;
} }
private _hideUnusedSprites(pool: Map<string, SpritePoolEntry>, used: Set<string>): void { /**
* Hide pooled sprites not in `used`. After {@link SPRITE_HIDDEN_EVICT_MS} of being hidden,
* remove from the pool and recycle or destroy the sprite.
*/
private _hideUnusedSprites(
pool: Map<string, SpritePoolEntry>,
used: Set<string>,
nowMs: number,
freeList?: Sprite[],
maxFreeListSize?: number,
): void {
const toRemove: string[] = [];
for (const [key, entry] of pool) { for (const [key, entry] of pool) {
entry.sprite.visible = used.has(key); if (used.has(key)) {
entry.sprite.visible = true;
entry.hiddenSinceMs = undefined;
continue;
}
entry.sprite.visible = false;
if (entry.hiddenSinceMs === undefined) {
entry.hiddenSinceMs = nowMs;
} else if (nowMs - entry.hiddenSinceMs >= SPRITE_HIDDEN_EVICT_MS) {
toRemove.push(key);
}
}
for (const key of toRemove) {
const entry = pool.get(key);
if (!entry) continue;
pool.delete(key);
const sprite = entry.sprite;
sprite.visible = false;
if (
freeList !== undefined &&
maxFreeListSize !== undefined &&
freeList.length < maxFreeListSize
) {
freeList.push(sprite);
} else {
sprite.destroy();
}
} }
} }
/** Drop GPU cache before mutating vector paths (Pixi 8: prefer cacheAsTexture over legacy cacheAsBitmap). */
private _releaseGraphicsTextureCache(gfx: Graphics | null): void {
if (!gfx?.isCachedAsTexture) return;
gfx.cacheAsTexture(false);
}
/**
* Bake static vector layers to a single quad. Skips huge bounds (see {@link MAX_STATIC_GRAPHICS_CACHE_AREA}).
* Do not use for per-frame animated Graphics (hero, enemy, NPC, thought bubbles).
*/
private _cacheStaticGraphicsIfBounded(gfx: Graphics | null): void {
if (!gfx || gfx.isCachedAsTexture) return;
const b = gfx.getLocalBounds();
const area = Math.max(0, b.width) * Math.max(0, b.height);
if (area < 16 || area > MAX_STATIC_GRAPHICS_CACHE_AREA) return;
gfx.cacheAsTexture({
antialias: false,
resolution: 1,
scaleMode: 'linear',
});
}
private _evictSpritesOutsideTileBounds( private _evictSpritesOutsideTileBounds(
pool: Map<string, SpritePoolEntry>, pool: Map<string, SpritePoolEntry>,
minX: number, minX: number,
@ -601,6 +670,9 @@ export class GameRenderer {
/** /**
* Draw visible ground tiles and decorative objects. * Draw visible ground tiles and decorative objects.
* Terrain is procedurally generated the world is endless. * Terrain is procedurally generated the world is endless.
*
* Vector fallback spans the full visible tile grid; we do not `cacheAsTexture` on `_groundGfx`
* (bounds would create a very large RenderTexture). Prefer terrain/object sprites when loaded.
*/ */
drawGround(camera: Camera, screenWidth: number, screenHeight: number): void { drawGround(camera: Camera, screenWidth: number, screenHeight: number): void {
const gfx = this._groundGfx; const gfx = this._groundGfx;
@ -817,9 +889,22 @@ export class GameRenderer {
} }
} }
const hideSpritesNow = performance.now();
if (spritesReady) { if (spritesReady) {
this._hideUnusedSprites(this._tileSpritePool, usedTileSprites); this._hideUnusedSprites(
this._hideUnusedSprites(this._objectSpritePool, usedObjectSprites); this._tileSpritePool,
usedTileSprites,
hideSpritesNow,
this._tileSpriteFreeList,
2800,
);
this._hideUnusedSprites(
this._objectSpritePool,
usedObjectSprites,
hideSpritesNow,
this._objectSpriteFreeList,
1800,
);
const evictPaddingTiles = 8; const evictPaddingTiles = 8;
this._evictSpritesOutsideTileBounds( this._evictSpritesOutsideTileBounds(
this._tileSpritePool, this._tileSpritePool,
@ -840,8 +925,20 @@ export class GameRenderer {
1800, 1800,
); );
} else { } else {
this._hideUnusedSprites(this._tileSpritePool, this._emptySpriteSet); this._hideUnusedSprites(
this._hideUnusedSprites(this._objectSpritePool, this._emptySpriteSet); this._tileSpritePool,
this._emptySpriteSet,
hideSpritesNow,
this._tileSpriteFreeList,
2800,
);
this._hideUnusedSprites(
this._objectSpritePool,
this._emptySpriteSet,
hideSpritesNow,
this._objectSpriteFreeList,
1800,
);
} }
} }
@ -1151,9 +1248,9 @@ export class GameRenderer {
entry.sprite.y = cy; entry.sprite.y = cy;
entry.sprite.roundPixels = true; entry.sprite.roundPixels = true;
const th = Math.max(1, tex.height || tex.width || 48); const th = Math.max(1, tex.height || tex.width || 48);
const targetH = 52; const targetH = 100;
entry.sprite.scale.set(targetH / th); entry.sprite.scale.set(targetH / th);
entry.sprite.zIndex = cy + 100; entry.sprite.zIndex = cy + 90;
entry.sprite.visible = true; entry.sprite.visible = true;
drawEnemyHpBarOnly(gfx, enemySlug, enemyArchetype, cx, cy, hp, maxHp); drawEnemyHpBarOnly(gfx, enemySlug, enemyArchetype, cx, cy, hp, maxHp);
return; return;
@ -1727,6 +1824,8 @@ export class GameRenderer {
this._lastTownDrawMs = now; this._lastTownDrawMs = now;
this._lastTownCameraX = cx; this._lastTownCameraX = cx;
this._lastTownCameraY = cy; this._lastTownCameraY = cy;
this._releaseGraphicsTextureCache(gfx);
this._releaseGraphicsTextureCache(iconGfx);
gfx.clear(); gfx.clear();
iconGfx.clear(); iconGfx.clear();
@ -1859,7 +1958,9 @@ export class GameRenderer {
labelIdx++; labelIdx++;
} }
this._hideUnusedSprites(this._buildingSpritePool, usedBuildingSprites); this._hideUnusedSprites(this._buildingSpritePool, usedBuildingSprites, now);
this._cacheStaticGraphicsIfBounded(gfx);
this._cacheStaticGraphicsIfBounded(iconGfx);
} }
/** /**
@ -1875,6 +1976,7 @@ export class GameRenderer {
): void { ): void {
const gfx = this._npcGfx; const gfx = this._npcGfx;
if (!gfx) return; if (!gfx) return;
// Per-frame idle sway + culling: keep vector path, no cacheAsTexture (would stale or rebuild every frame).
gfx.clear(); gfx.clear();
for (const lbl of this._npcLabels) { for (const lbl of this._npcLabels) {
@ -2109,7 +2211,7 @@ export class GameRenderer {
} }
} }
this._hideUnusedSprites(this._npcSpritePool, usedNpcSprites); this._hideUnusedSprites(this._npcSpritePool, usedNpcSprites, now);
} }
/** /**
@ -2158,7 +2260,13 @@ export class GameRenderer {
} }
} }
this._hideUnusedSprites(this._townObjectSpritePool, usedTownObjects); this._hideUnusedSprites(
this._townObjectSpritePool,
usedTownObjects,
performance.now(),
this._townObjectSpriteFreeList,
1200,
);
} }
/** Clear NPC visuals when there are none to render */ /** Clear NPC visuals when there are none to render */
@ -2167,7 +2275,7 @@ export class GameRenderer {
for (const lbl of this._npcLabels) { for (const lbl of this._npcLabels) {
lbl.visible = false; lbl.visible = false;
} }
this._hideUnusedSprites(this._npcSpritePool, this._emptySpriteSet); this._hideUnusedSprites(this._npcSpritePool, this._emptySpriteSet, performance.now());
} }
/** /**
@ -2261,7 +2369,7 @@ export class GameRenderer {
gfx.zIndex = cy + 100; gfx.zIndex = cy + 100;
} }
this._hideUnusedSprites(this._nearbyHeroSpritePool, usedNearbySprites); this._hideUnusedSprites(this._nearbyHeroSpritePool, usedNearbySprites, performance.now());
} }
/** /**
@ -2335,7 +2443,7 @@ export class GameRenderer {
for (const lbl of this._nearbyHeroLabels) { for (const lbl of this._nearbyHeroLabels) {
lbl.visible = false; lbl.visible = false;
} }
this._hideUnusedSprites(this._nearbyHeroSpritePool, this._emptySpriteSet); this._hideUnusedSprites(this._nearbyHeroSpritePool, this._emptySpriteSet, performance.now());
} }
/** Sort entity layer by y-position for correct isometric depth */ /** Sort entity layer by y-position for correct isometric depth */

Loading…
Cancel
Save