mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-17 22:31:26 +02:00
make character customization projection aware
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 })
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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: {} });
|
||||
|
||||
Reference in New Issue
Block a user