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: {} });