make character customization projection aware

This commit is contained in:
jwinterm
2026-07-06 06:38:19 -04:00
parent c89c01e5f1
commit f505a4fb8a
8 changed files with 174 additions and 37 deletions
+1 -1
View File
@@ -193,7 +193,7 @@
pack: 'iso-dungeon',
tile: { w: 84, h: 42, imageW: 92, imageH: 184 },
tiles: {
floor: 'assets/kenney/iso-dungeon/tiles/stone_S.png',
floor: 'assets/kenney/iso-dungeon/tiles/planks_S.png',
wall: 'assets/kenney/iso-dungeon/tiles/stoneWall_S.png',
bar: 'assets/kenney/iso-dungeon/tiles/barrelsStacked_S.png',
table: 'assets/kenney/iso-dungeon/tiles/chestClosed_S.png',
+15
View File
@@ -48,6 +48,20 @@
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';
@@ -280,6 +294,7 @@
normalize: normalize,
kindFor: kindFor,
resolve: resolve,
tintColorFor: tintColorFor,
ensureCanvasReady: ensureCanvasReady,
drawIcon: drawIcon,
drawTopdownWorld: drawTopdownWorld
+47 -10
View File
@@ -62,17 +62,21 @@
var avatar = input.avatar || input.id || 'default';
if (!baseAppearance(avatar) || baseAppearance(avatar).id !== avatar) avatar = 'default';
var base = baseAppearance(avatar);
var isChar = !!(RK.isChar && RK.isChar(avatar));
var isModel = base && base.kind === 'model3d';
var tint = 'none';
var equipment = cloneEquipment(DEFAULT_EQUIPMENT);
var colors = null;
if (isChar) {
if (isChar || isModel) {
colors = cloneColors(input.colors, input.tint);
tint = colors.base;
}
if (isChar) {
equipment = RK.normalizeCharEquipment ? RK.normalizeCharEquipment(input.equipment) : cloneEquipment(input.equipment);
}
var out = { avatar: avatar, tint: tint, equipment: equipment };
if (isChar) out.colors = colors;
if (isChar || isModel) out.colors = colors;
return out;
};
@@ -177,7 +181,7 @@
ctx.beginPath(); ctx.moveTo(17, 26); ctx.lineTo(35, 26); ctx.stroke();
return;
}
var colorable = item.colorable !== false;
var colorable = item.colorable === true;
var tint = colorable ? ((draft.colors && draft.colors[slot]) || draft.tint) : 'none';
var colors = colorable ? draft.colors : null;
if (!RK.drawCharTileCanvas || !RK.drawCharTileCanvas(ctx, item.frame, tint, 2, 2, 48, 48, colors, slot)) {
@@ -252,12 +256,30 @@
return !!(RK.isChar && RK.isChar(draft.avatar));
}
function ensureCharDraft() {
function draftKind() {
return (baseAppearance(draft.avatar) || {}).kind;
}
function supportsColorDraft() {
var kind = draftKind();
return isCharDraft() || kind === 'model3d';
}
function activeProjection() {
if (selectedMode === 'iso' || selectedMode === '3d' || selectedMode === 'ascii') return selectedMode;
return 'topdown';
}
function ensureColorDraft() {
if (!draft.colors) draft.colors = cloneColors(null, draft.tint);
if (!draft.equipment) draft.equipment = cloneEquipment(DEFAULT_EQUIPMENT);
draft.tint = draft.colors.base;
}
function ensureCharDraft() {
ensureColorDraft();
if (!draft.equipment) draft.equipment = cloneEquipment(DEFAULT_EQUIPMENT);
}
var wrap = document.createElement('div');
wrap.className = 'rk-customize';
@@ -433,6 +455,7 @@
selectedMode = mode.id;
if (RK.saveMode) RK.saveMode(mode.id);
markRenderMode();
renderEditor();
}
function buildRenderModes() {
@@ -482,18 +505,29 @@
}
function equipmentColorable(item) {
return !!(item && item.frame != null && item.colorable !== false);
return !!(item && item.frame != null && item.colorable === true);
}
function renderEditor() {
editSection.innerHTML = '';
if (!isCharDraft()) return;
ensureCharDraft();
if (!supportsColorDraft()) return;
if (isCharDraft()) ensureCharDraft();
else ensureColorDraft();
var projection = activeProjection();
if (projection !== 'topdown' || !isCharDraft()) {
sectionTitle('Color', editSection);
renderColorRow('Color', 'base', 'tint', editSection);
return;
}
var bodyItem = draft.equipment && draft.equipment.body;
var headItem = draft.equipment && draft.equipment.head;
sectionTitle('Color', editSection);
renderColorRow('Cloth', 'base', 'tint', editSection);
if (!bodyItem || bodyItem === 'none') renderColorRow('Cloth', 'base', 'tint', editSection);
renderColorRow('Skin', 'skin', 'skin', editSection);
renderColorRow('Hair', 'hair', 'hair', editSection);
if (!headItem || headItem === 'none') renderColorRow('Hair', 'hair', 'hair', editSection);
sectionTitle('Equipment', editSection);
(RK.CHAR_EQUIPMENT_SLOTS || []).forEach(function (slot) {
@@ -520,6 +554,9 @@
draft.avatar = a.id;
if (RK.isChar && RK.isChar(draft.avatar)) {
ensureCharDraft();
} else if (draftKind() === 'model3d') {
ensureColorDraft();
draft.equipment = cloneEquipment(DEFAULT_EQUIPMENT);
} else {
draft.tint = 'none';
draft.equipment = cloneEquipment(DEFAULT_EQUIPMENT);
+1 -19
View File
@@ -33,22 +33,6 @@
'192,117,28': 2,
'198,101,39': 3
};
var GEAR_SOURCE = {
'114,88,57': 0,
'149,92,24': 1,
'163,84,34': 1,
'176,128,82': 1,
'177,92,35': 2,
'192,117,28': 2,
'198,101,39': 3,
'200,164,128': 2,
'135,135,135': 0,
'202,202,202': 2,
'204,204,204': 2,
'232,232,232': 3,
'247,247,247': 3
};
RK.CHAR_TINTS = {
none: { id: 'none', label: 'Natural', color: null, ramp: null },
rose: { id: 'rose', label: 'Rose', color: '#d85c78', ramp: ['#8f3d54', '#b84f6b', '#d96f8c', '#f4aac0'] },
@@ -266,8 +250,6 @@
if (slot === 'base') {
applyRamp(img, SKIN_SOURCE, skin && colors.skin !== 'natural' ? skin.ramp.map(hexToRgb) : null);
applyRamp(img, HAIR_SOURCE, hair && colors.hair !== 'copper' ? hair.ramp.map(hexToRgb) : null);
} else {
applyRamp(img, GEAR_SOURCE, slotRamp);
}
applyRamp(img, TINT_SOURCE, slotRamp);
ctx.putImageData(img, 0, 0);
@@ -313,7 +295,7 @@
EQUIP_SLOTS.forEach(function (slot) {
var item = RK.CHAR_EQUIPMENT[slot][eq[slot]];
if (!item || item.frame == null) return;
var colorable = item.colorable !== false;
var colorable = item.colorable === true;
out.push({ slot: slot, id: item.id, frame: item.frame, tint: colorable ? tint : 'none', colorable: colorable });
});
return out;
+27 -3
View File
@@ -16,6 +16,7 @@
this.canvas.className = 'rk-canvas';
this.ctx = this.canvas.getContext('2d');
this.cache = {};
this.tintCache = {};
this.last = {};
this.lastScene = null;
this.enabled = !(root.RK && RK.canUsePack && this.assets.pack && !RK.canUsePack(this.assets.pack));
@@ -64,6 +65,25 @@
this.ctx.drawImage(img, cx - w / 2, baseY - h, w, h);
};
IsoRenderer.prototype._tintedImage = function (img, tint) {
if (!img || !tint) return img;
var key = img.src + '|' + tint;
if (this.tintCache[key]) return this.tintCache[key];
var cv = document.createElement('canvas');
cv.width = img.naturalWidth || img.width;
cv.height = img.naturalHeight || img.height;
var ctx = cv.getContext('2d');
ctx.drawImage(img, 0, 0);
ctx.globalCompositeOperation = 'source-atop';
ctx.globalAlpha = 0.42;
ctx.fillStyle = tint;
ctx.fillRect(0, 0, cv.width, cv.height);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
this.tintCache[key] = cv;
return cv;
};
IsoRenderer.prototype._tileUrl = function (kind) {
var tiles = this.assets.tiles || {};
return tiles[kind] || tiles.fallback || tiles.floor;
@@ -86,9 +106,9 @@
if (st.x !== e.x || st.y !== e.y) { st.x = e.x; st.y = e.y; st.t = now; }
var moving = (now - st.t) < 360;
var ch = (visual && visual.character) || (this.assets.character || {});
if (!moving || !ch.run || ch.run.length === 0) return { url: ch.idle, character: ch };
if (!moving || !ch.run || ch.run.length === 0) return { url: ch.idle, character: ch, visual: visual };
this._animating = true;
return { url: ch.run[Math.floor(now / 80) % ch.run.length], character: ch };
return { url: ch.run[Math.floor(now / 80) % ch.run.length], character: ch, visual: visual };
};
IsoRenderer.prototype.render = function (scene) {
@@ -140,7 +160,10 @@
rec = this._load(frame && frame.url);
if (rec && rec.ready) {
var ch = frame.character || this.assets.character || {};
this._drawImage(rec.img, it.sx, it.sy + this.tileH * 0.95, ch.imageW || 74, ch.imageH || 148);
var tint = root.RK && RK.avatarVisuals && RK.avatarVisuals.tintColorFor
? RK.avatarVisuals.tintColorFor(frame.visual && frame.visual.appearance)
: null;
this._drawImage(this._tintedImage(rec.img, tint), it.sx, it.sy + this.tileH * 0.95, ch.imageW || 74, ch.imageH || 148);
} else {
ctx.beginPath();
ctx.arc(it.sx, it.sy, 8, 0, Math.PI * 2);
@@ -171,6 +194,7 @@
this.canvas = null;
this.ctx = null;
this.cache = {};
this.tintCache = {};
this.last = {};
this.lastScene = null;
};
+29 -4
View File
@@ -4,6 +4,12 @@
'use strict';
function colorNum(hex) { return parseInt(String(hex || '#9aa4b2').replace('#', ''), 16) || 0x9aa4b2; }
function tintForVisual(visual, fallback) {
if (root.RK && RK.avatarVisuals && RK.avatarVisuals.tintColorFor) {
return RK.avatarVisuals.tintColorFor(visual && visual.appearance, fallback);
}
return fallback || null;
}
function ThreeRenderer(host, opts) {
opts = opts || {};
@@ -82,10 +88,10 @@
this.world.position.set(0, 0, 0);
};
ThreeRenderer.prototype._fallbackAvatar = function (e) {
ThreeRenderer.prototype._fallbackAvatar = function (e, visual) {
var T = this.THREE;
var g = new T.Group();
var body = new T.Mesh(new T.CapsuleGeometry(0.44, 1.18, 4, 12), this._mat(e.color || '#9aa4b2'));
var body = new T.Mesh(new T.CapsuleGeometry(0.44, 1.18, 4, 12), this._mat(tintForVisual(visual, e.color || '#9aa4b2')));
body.position.y = 1.0;
g.add(body);
var face = new T.Mesh(new T.BoxGeometry(0.22, 0.12, 0.06), this._mat('#0a0c0f'));
@@ -94,6 +100,24 @@
return g;
};
ThreeRenderer.prototype._applyModelTint = function (model, visual) {
var tint = tintForVisual(visual, null);
if (!tint || !model) return;
var T = this.THREE;
var color = new T.Color(colorNum(tint));
model.traverse(function (obj) {
if (!obj.isMesh || !obj.material) return;
var materials = Array.isArray(obj.material) ? obj.material : [obj.material];
var next = materials.map(function (mat) {
if (!mat || !mat.color) return mat;
var copy = mat.clone();
copy.color.copy(color);
return copy;
});
obj.material = Array.isArray(obj.material) ? next : next[0];
});
};
ThreeRenderer.prototype._fitModel = function (model) {
var T = this.THREE;
var wrapper = new T.Group();
@@ -166,13 +190,14 @@
ThreeRenderer.prototype._makeEntity = function (e) {
var T = this.THREE, self = this;
var shell = new T.Group();
shell._body = this._fallbackAvatar(e);
shell.add(shell._body);
var visual = this._visualFor(e);
shell._body = this._fallbackAvatar(e, visual);
shell.add(shell._body);
if (visual && visual.allowed !== false && visual.model) {
this._loadModel(visual, function (rec) {
if (!rec || !rec.gltf || !shell.parent) return;
var model = rec.gltf.scene.clone(true);
self._applyModelTint(model, visual);
model.rotation.y = Math.PI;
var fitted = self._fitModel(model);
shell.add(fitted);
+35
View File
@@ -0,0 +1,35 @@
const fs = require('fs');
const path = require('path');
const vm = require('vm');
function loadCharSprites() {
const context = {
console,
window: { console }
};
context.window.window = context.window;
vm.runInNewContext(
fs.readFileSync(path.join(__dirname, '../html/js/render/charSprites.js'), 'utf8'),
context
);
return context.window.RK;
}
describe('roguelike character sprites', () => {
test('equipment stays fixed color unless an asset explicitly opts into tinting', () => {
const RK = loadCharSprites();
const parts = RK.charOverlayParts({
avatar: 'char-wizard',
tint: 'gold',
colors: { base: 'gold', body: 'moss', head: 'teal' },
equipment: { body: 'mail', head: 'helm', shield: 'round', weapon: 'sword' }
});
expect(parts).toEqual(expect.arrayContaining([
expect.objectContaining({ slot: 'body', id: 'mail', tint: 'none', colorable: false }),
expect.objectContaining({ slot: 'head', id: 'helm', tint: 'none', colorable: false }),
expect.objectContaining({ slot: 'shield', id: 'round', tint: 'none', colorable: false }),
expect.objectContaining({ slot: 'weapon', id: 'sword', tint: 'none', colorable: false })
]));
});
});
+19
View File
@@ -37,6 +37,10 @@ function loadRenderKit() {
'monero-knight': { id: 'monero-knight', label: 'Monero Knight', pack: 'generated-skins' }
};
RK.isSkin = id => !!RK.SKINS[id];
RK.CHAR_TINTS = {
none: { id: 'none', label: 'Natural', color: null },
gold: { id: 'gold', label: 'Gold', color: '#d29922' }
};
RK.CHARS = { 'char-ranger': { id: 'char-ranger', label: 'Ranger', frame: 432 } };
RK.isChar = id => !!RK.CHARS[id];
@@ -87,6 +91,21 @@ describe('render pack visual resolver', () => {
expect(compat.model.url).toContain('survivorFemaleA.glb');
});
test('iso floor uses a flat tile instead of a raised block tile', () => {
const RK = loadRenderKit();
expect(RK.isoAssets.tiles.floor).toContain('planks_S.png');
expect(RK.isoAssets.tiles.floor).not.toContain('stone_S.png');
});
test('projection visuals expose the saved base tint as a render color', () => {
const RK = loadRenderKit();
const visual = RK.avatarVisuals.resolve(
{ avatar: 'char-ranger', colors: { base: 'gold' }, tint: 'gold' },
{ projection: 'iso' }
);
expect(RK.avatarVisuals.tintColorFor(visual.appearance)).toBe('#d29922');
});
test('renderer factory falls back when premium modes are locked', () => {
const RK = loadRenderKit();
RK.setEntitlementSnapshot({ totalCreditsPurchased: 0, premium: false, packs: {} });