diff --git a/html/js/render/assetPacks.js b/html/js/render/assetPacks.js
index 65e8ec5..ae0a94e 100644
--- a/html/js/render/assetPacks.js
+++ b/html/js/render/assetPacks.js
@@ -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',
diff --git a/html/js/render/avatarVisuals.js b/html/js/render/avatarVisuals.js
index e14cd85..9e85f50 100644
--- a/html/js/render/avatarVisuals.js
+++ b/html/js/render/avatarVisuals.js
@@ -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
diff --git a/html/js/render/charCustomize.js b/html/js/render/charCustomize.js
index 26445c3..923e583 100644
--- a/html/js/render/charCustomize.js
+++ b/html/js/render/charCustomize.js
@@ -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);
diff --git a/html/js/render/charSprites.js b/html/js/render/charSprites.js
index c3e695c..8177b97 100644
--- a/html/js/render/charSprites.js
+++ b/html/js/render/charSprites.js
@@ -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;
diff --git a/html/js/render/isoRenderer.js b/html/js/render/isoRenderer.js
index 7d6d596..c9ce08f 100644
--- a/html/js/render/isoRenderer.js
+++ b/html/js/render/isoRenderer.js
@@ -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;
};
diff --git a/html/js/render/threeRenderer.js b/html/js/render/threeRenderer.js
index a1fd243..fe4bf49 100644
--- a/html/js/render/threeRenderer.js
+++ b/html/js/render/threeRenderer.js
@@ -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);
diff --git a/test/charSprites.test.js b/test/charSprites.test.js
new file mode 100644
index 0000000..9cba9cb
--- /dev/null
+++ b/test/charSprites.test.js
@@ -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 })
+ ]));
+ });
+});
diff --git a/test/renderPackResolver.test.js b/test/renderPackResolver.test.js
index f9b8947..74f46c6 100644
--- a/test/renderPackResolver.test.js
+++ b/test/renderPackResolver.test.js
@@ -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: {} });