Files
wownerogue/html/js/render/avatarVisuals.js
T
jwinterm 588250e5a9 fix(render): default avatar renders as a real character, not a grey circle
A player who never customized arrives as avatar 'default' — not a char/skin/model,
so avatarVisuals resolved it to 'legacy' kind with no sprite and the renderer drew
the grey fallback circle (while NPCs with explicit char-* avatars rendered fine).
Coerce any unknown/'default' avatar to 'char-villager' on the 2D topdown path so
nobody is ever a grey dot in a room of people. iso/3d keep their own fallbacks.
2026-07-10 22:41:58 -04:00

313 lines
13 KiB
JavaScript

// AvatarVisuals is the render-kit resolver for turning a saved appearance identity into a
// drawable visual for a specific projection/context. Legacy ROT screens can use fallback tiles;
// canvas-capable renderers can draw the same identity as a composed character or premium skin.
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
var DEFAULT_EQUIPMENT = { body: 'none', head: 'none', shield: 'none', weapon: 'none' };
var DEFAULT_APPEARANCE = { avatar: 'default', tint: 'none', equipment: DEFAULT_EQUIPMENT };
var loading = {};
function normalize(appearance) {
if (RK.normalizeAppearance) return RK.normalizeAppearance(appearance || DEFAULT_APPEARANCE);
return DEFAULT_APPEARANCE;
}
function topdownPackFor(appearance, kind) {
if (kind === 'skin') {
var skin = RK.SKINS && RK.SKINS[appearance.avatar];
return (skin && skin.pack) || 'generated-skins';
}
if (kind === 'model3d') return 'kenney-3d-characters';
return 'roguelike-interior';
}
function kindFor(appearance, projection) {
if (!appearance) return 'legacy';
if (projection === 'iso') return 'iso';
if (projection === '3d') return 'model3d';
if (RK.isChar && RK.isChar(appearance.avatar)) return 'char';
if (RK.isSkin && RK.isSkin(appearance.avatar)) return 'skin';
if (appearance.avatar && /^kenney-/.test(appearance.avatar)) return 'model3d';
return 'legacy';
}
function fallbackTile(opts) {
opts = opts || {};
if (opts.fallbackTile) return opts.fallbackTile;
if (root.GameTiles && root.GameTiles.getPlayerTile) return root.GameTiles.getPlayerTile();
return '@';
}
function labelFor(appearance) {
if (RK.appearance) {
var info = RK.appearance(appearance);
if (info && info.label) return info.label;
}
return 'Character';
}
function tintColorFor(appearance, fallback) {
var ap = normalize(appearance);
var base = ap && ap.colors && ap.colors.base;
var tint = base || (ap && ap.tint);
if (tint && tint !== 'none' && RK.CHAR_TINTS && RK.CHAR_TINTS[tint] && RK.CHAR_TINTS[tint].color) {
return RK.CHAR_TINTS[tint].color;
}
if (RK.appearance) {
var info = RK.appearance(ap);
if (info && info.color) return info.color;
}
return fallback || null;
}
function packFor(appearance, kind, projection) {
if (projection === 'iso') return 'iso-dungeon';
if (projection === '3d') return 'kenney-3d-characters';
return topdownPackFor(appearance, kind);
}
function canUsePack(id) {
return !id || !RK.canUsePack || RK.canUsePack(id);
}
function isoCharacterFor(appearance) {
var assets = RK.isoAssets || {};
var chars = assets.characters || {};
return chars[appearance.avatar] || chars.fallback || assets.character || null;
}
function modelFor(appearance) {
var assets = RK.threeAssets || {};
var models = assets.models || {};
return models[appearance.avatar] || models.fallback || null;
}
function resolve(appearance, opts) {
opts = opts || {};
var ap = normalize(appearance);
var projection = opts.projection || 'legacy-rot';
// A player who never customized arrives as avatar 'default' (or an unknown id), which is not
// a char/skin/model and would fall to the grey circle. On the 2D topdown/tavern path, coerce
// it to a real Kenney character so nobody is ever a grey dot in a room of people. (iso/3d
// keep their own fallbacks below.)
if (ap && projection !== 'iso' && projection !== '3d') {
var a = ap.avatar;
var known = (RK.isChar && RK.isChar(a)) || (RK.isSkin && RK.isSkin(a)) || (a && /^kenney-/.test(a));
if (!known) ap = Object.assign({}, ap, { avatar: 'char-villager' });
}
var kind = kindFor(ap, projection);
var pack = packFor(ap, kind, projection);
var visual = {
appearance: ap,
kind: kind,
projection: projection,
context: opts.context || 'player',
label: labelFor(ap),
pack: pack,
allowed: canUsePack(pack),
fallbackTile: fallbackTile(opts),
canvas: kind === 'char' || kind === 'skin',
entity: opts.entity || null
};
if (projection === 'iso') {
visual.assets = RK.isoAssets || null;
visual.character = isoCharacterFor(ap);
visual.canvas = false;
} else if (projection === '3d') {
visual.assets = RK.threeAssets || null;
visual.model = modelFor(ap);
visual.canvas = false;
visual.three = true;
}
return visual;
}
function rememberLoad(key, start, onReady) {
if (loading[key]) {
if (onReady) loading[key].push(onReady);
return;
}
loading[key] = onReady ? [onReady] : [];
start(function () {
var cbs = loading[key] || [];
delete loading[key];
cbs.forEach(function (cb) { try { cb(); } catch (_) { /* ignore */ } });
});
}
function ensureCanvasReady(visual, onReady) {
visual = visual && visual.appearance ? visual : resolve(visual);
if (!visual.allowed) return false;
if (visual.kind === 'char') {
if (RK.charAtlas && RK.charAtlas()) return true;
if (!RK.loadCharAtlas) return false;
rememberLoad('char-atlas', function (done) { RK.loadCharAtlas(done); }, onReady);
return false;
}
if (visual.kind === 'skin') {
if (RK.skinSheet && RK.skinSheet(visual.appearance.avatar)) return true;
if (!RK.loadSkin) return false;
rememberLoad('skin:' + visual.appearance.avatar, function (done) {
var rec = RK.loadSkin(visual.appearance.avatar);
if (rec && rec.cbs) rec.cbs.push(done);
}, onReady);
return false;
}
return false;
}
function drawCharIcon(ctx, visual, x, y, size) {
if (!RK.drawCharCompositeCanvas) return false;
return RK.drawCharCompositeCanvas(ctx, visual.appearance, x, y, size);
}
function drawSkinIcon(ctx, visual, x, y, size) {
var rec = RK.skinSheet && RK.skinSheet(visual.appearance.avatar);
var skin = RK.SKINS && RK.SKINS[visual.appearance.avatar];
if (!rec || !skin) return false;
var row = (skin.dirRows && skin.dirRows.down) || 0;
var dh = size;
var dw = dh * (skin.frameW / skin.frameH);
ctx.imageSmoothingEnabled = true;
ctx.drawImage(rec.img, 0, row * skin.frameH, skin.frameW, skin.frameH, x + (size - dw) / 2, y, dw, dh);
return true;
}
function drawIcon(ctx, visual, rect, opts) {
opts = opts || {};
visual = visual && visual.appearance ? visual : resolve(visual, opts);
if (!ctx || !rect || !ensureCanvasReady(visual, opts.onReady)) return false;
var boxW = rect.w || rect.size || 32;
var boxH = rect.h || rect.size || 32;
var natural = Math.min(boxW, boxH);
var legend = visual.context === 'legend' || opts.context === 'legend';
var scale = opts.scale || (legend ? 0.78 : 1);
var size = opts.size || Math.max(1, Math.round(natural * scale));
var offsetX = opts.offsetX || 0;
var offsetY = opts.offsetY != null ? opts.offsetY : (legend ? Math.round(natural * 0.08) : 0);
var x = rect.x + Math.max(0, (boxW - size) / 2) + offsetX;
var y = rect.y + Math.max(0, (boxH - size) / 2) + offsetY;
ctx.clearRect(rect.x, rect.y, boxW, boxH);
if (visual.kind === 'char') return drawCharIcon(ctx, visual, x, y, size);
if (visual.kind === 'skin') return drawSkinIcon(ctx, visual, x, y, size);
return false;
}
function drawTopdownChar(ctx, visual, entity, viewport, opts) {
opts = opts || {};
var ch = RK.CHARS && RK.CHARS[visual.appearance.avatar];
if (!ch || !RK.charFrame || !RK.drawCharTileCanvas) return false;
var cell = viewport.cell || 32;
var e = {
id: entity.id || 'avatar',
x: entity.x || 0,
y: entity.y || 0,
facing: entity.facing || 'down'
};
var frame = RK.charFrame(ch, e, opts.now || Date.now());
var dw = cell * (opts.scale || 1.8);
var dh = dw * frame.squash;
var cx = viewport.screenX * cell + cell / 2;
var dy = (viewport.screenY * cell + cell) - dh + frame.bob;
var ap = RK.charAppearance({ avatar: visual.appearance.avatar, appearance: visual.appearance });
ctx.imageSmoothingEnabled = false;
if (entity.you) {
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(cx, viewport.screenY * cell + cell - 2, dw * 0.3, cell * 0.16, 0, 0, Math.PI * 2);
ctx.stroke();
}
function drawComposite(dx, dy2) {
RK.drawCharTileCanvas(ctx, ch.frame, ap.tint, dx, dy2, dw, dh, ap.colors, 'base');
RK.charOverlayParts(ap).forEach(function (part) {
RK.drawCharTileCanvas(ctx, part.frame, part.tint, dx, dy2, dw, dh, part.colorable ? ap.colors : null, part.slot);
});
}
if (frame.flip) {
ctx.save();
ctx.translate(cx + dw / 2, dy);
ctx.scale(-1, 1);
drawComposite(0, 0);
ctx.restore();
} else {
drawComposite(cx - dw / 2, dy);
}
if (entity.label) {
ctx.fillStyle = '#d7dbe0';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(entity.label, cx, dy - 2);
}
return true;
}
function drawTopdownSkin(ctx, visual, entity, viewport, opts) {
opts = opts || {};
var rec = RK.skinSheet && RK.skinSheet(visual.appearance.avatar);
var skin = RK.SKINS && RK.SKINS[visual.appearance.avatar];
if (!rec || !skin || !RK.skinFrame) return false;
var cell = viewport.cell || 32;
var e = {
id: entity.id || 'avatar-skin',
x: entity.x || 0,
y: entity.y || 0,
facing: entity.facing || 'down'
};
var frame = RK.skinFrame(skin, e, opts.now || Date.now());
var dh = cell * (skin.scale || 1.7);
var dw = dh * (skin.frameW / skin.frameH);
var cx = viewport.screenX * cell + cell / 2;
var dx = cx - dw / 2;
var dy = (viewport.screenY * cell + cell) - dh;
ctx.imageSmoothingEnabled = true;
var midY = dy + dh * 0.45;
var halo = ctx.createRadialGradient(cx, midY, 0, cx, midY, dw * 0.8);
halo.addColorStop(0, 'rgba(255,226,180,0.30)');
halo.addColorStop(1, 'rgba(255,226,180,0)');
ctx.fillStyle = halo;
ctx.beginPath();
ctx.arc(cx, midY, dw * 0.8, 0, Math.PI * 2);
ctx.fill();
if (entity.you) {
ctx.strokeStyle = 'rgba(255,255,255,0.85)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(cx, viewport.screenY * cell + cell - 2, dw * 0.3, cell * 0.16, 0, 0, Math.PI * 2);
ctx.stroke();
}
ctx.drawImage(rec.img, frame.col * skin.frameW, frame.row * skin.frameH, skin.frameW, skin.frameH, dx, dy, dw, dh);
if (entity.label) {
ctx.fillStyle = '#d7dbe0';
ctx.font = '11px monospace';
ctx.textAlign = 'center';
ctx.fillText(entity.label, cx, dy - 2);
}
return true;
}
function drawTopdownWorld(ctx, visual, entity, viewport, opts) {
opts = opts || {};
visual = visual && visual.appearance ? visual : resolve(visual, opts);
if (!ctx || !entity || !viewport || !visual.allowed || !ensureCanvasReady(visual, opts.onReady)) return false;
if (visual.kind === 'char') return drawTopdownChar(ctx, visual, entity, viewport, opts);
if (visual.kind === 'skin') return drawTopdownSkin(ctx, visual, entity, viewport, opts);
return false;
}
RK.avatarVisuals = {
DEFAULT_APPEARANCE: DEFAULT_APPEARANCE,
normalize: normalize,
kindFor: kindFor,
resolve: resolve,
tintColorFor: tintColorFor,
ensureCanvasReady: ensureCanvasReady,
drawIcon: drawIcon,
drawTopdownWorld: drawTopdownWorld
};
RK.resolveAvatarVisual = resolve;
})(window);