角色面板

This commit is contained in:
李鹏宇
2026-09-03 15:31:15 +08:00
parent 032d7f735e
commit 43e1ef7fce
14 changed files with 980 additions and 315 deletions
+99 -79
View File
@@ -1,24 +1,56 @@
// assets/characters.js — 微观世界角色动画系统(6种族×5动作)
'use strict';
// ---- 种族精灵表配置(含种族数值:移速/攻击力/最大生命/恢复能力)----
const RACE_CONFIG = {
human: { name: '人族', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。各项能力标准,无特殊被动,适合新手。', walk: 'assets/characters/human/human_walk_1x6.png', idle_attack: 'assets/characters/human/human_idle_attack_1x6.png', work_comm: 'assets/characters/human/human_work_comm_1x6.png' },
demon: { name: '妖族', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。无特殊被动,与人类齐平的基础战力。', walk: 'assets/characters/demon/demon_walk_1x6.png', idle_attack: 'assets/characters/demon/demon_idle_attack_1x6.png', work_comm: 'assets/characters/demon/demon_work_comm_1x6.png' },
dwarf: { name: '矮人', speed: 72, attack: 5, hpMax: 50, regen: false, enrage: true, shieldRatio: 0.10, trait: '铁壁·狂暴:移速较慢(0.8倍);每损失10%生命攻击+5%;生命≤30%时获得10%最大生命护盾。', walk: 'assets/characters/dwarf/dwarf_walk_1x6.png', idle_attack: 'assets/characters/dwarf/dwarf_idle_attack_1x6.png', work_comm: 'assets/characters/dwarf/dwarf_work_comm_1x6.png' },
elf: { name: '精灵', speed: 135, attack: 5, hpMax: 50, regen: false, trait: '疾风:移速飞快(1.5倍),机动性最强的种族。', walk: 'assets/characters/elf/elf_walk_1x6.png', idle_attack: 'assets/characters/elf/elf_idle_attack_1x6.png', work_comm: 'assets/characters/elf/elf_work_comm_1x6.png' },
dragon: { name: '龙族', speed: 90, attack: 8, attackRange: 90, hpMax: 50, regen: false, trait: '龙威:攻击力高(8),攻击范围大(1.5倍),爆发型输出种族。', walk: 'assets/characters/dragon/dragon_walk_1x6.png', idle_attack: 'assets/characters/dragon/dragon_idle_attack_1x6.png', work_comm: 'assets/characters/dragon/dragon_work_comm_1x6.png' },
undead: { name: '不死族', speed: 90, attack: 4, hpMax: 100, regen: true, trait: '不死:生命极高(100);脱战10秒后每秒恢复5%最大生命。', walk: 'assets/characters/undead/undead_walk_1x6.png', idle_attack: 'assets/characters/undead/undead_idle_attack_1x6.png', work_comm: 'assets/characters/undead/undead_work_comm_1x6.png' },
orc: { name: '兽人', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。绿皮獠牙的战士,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/orc/orc_walk_1x6.png', idle_attack: 'assets/characters/orc/orc_idle_attack_1x6.png', work_comm: 'assets/characters/orc/orc_work_comm_1x6.png' },
angel: { name: '天使', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。头顶光环的天界战士,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/angel/angel_walk_1x6.png', idle_attack: 'assets/characters/angel/angel_idle_attack_1x6.png', work_comm: 'assets/characters/angel/angel_work_comm_1x6.png' },
werewolf:{ name: '狼人', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。竖耳狼首的猎手,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/werewolf/werewolf_walk_1x6.png', idle_attack: 'assets/characters/werewolf/werewolf_idle_attack_1x6.png', work_comm: 'assets/characters/werewolf/werewolf_work_comm_1x6.png' },
golem: { name: '石傀儡', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。眼中发光的岩石巨像,无特殊被动,基础战力与人类一致。', walk: 'assets/characters/golem/golem_walk_1x6.png', idle_attack: 'assets/characters/golem/golem_idle_attack_1x6.png', work_comm: 'assets/characters/golem/golem_work_comm_1x6.png' },
};
// ---- 种族精灵表与数值(单一事实来源 = Odoo yt.world.race,前端不再写死)----
// RACE_CONFIG 初始为空,进入微观世界时由 loadRaces() 从 /api/races 填充。
let RACE_CONFIG = {};
// 从 Odoo 拉取种族模板(含精灵图路径与全部数值),覆盖 RACE_CONFIG。
// 失败则保持空表,raceStats 会退回安全默认值,绝不依赖前端写死数据。
async function loadRaces() {
if (typeof API === 'undefined' || !API.races) return false;
try {
const r = await API.races();
if (!r || !r.ok || !Array.isArray(r.races)) return false;
const map = {};
r.races.forEach(rc => {
map[rc.code] = {
name: rc.name,
icon: rc.icon || '👤',
speed: rc.speed,
attack: rc.attack,
defense: rc.defense,
hpMax: rc.hp_max,
energyMax: rc.energy_max,
attackRange: rc.attack_range,
regen: !!rc.regen,
enrage: !!rc.enrage,
shieldRatio: rc.shield_ratio || 0,
trait: rc.trait || '',
walk: rc.sprite_walk,
idle_attack: rc.sprite_idle,
work_comm: rc.sprite_work,
};
});
RACE_CONFIG = map;
window.RACE_CONFIG = RACE_CONFIG;
return true;
} catch (e) {
console.warn('[loadRaces] 拉取种族模板失败,退回默认值', e);
return false;
}
}
function raceStats(race) {
const rc = RACE_CONFIG[race] || RACE_CONFIG.human;
if (!rc) {
// 极端兜底:模板尚未加载时给通用安全值,不写死任何种族差异
return { speed: 90, attack: 5, attackRange: CHAR_ATTACK_RANGE, hpMax: 50,
regen: false, enrage: false, shieldRatio: 0, defense: 3 };
}
return {
speed: rc.speed, attack: rc.attack, attackRange: rc.attackRange || CHAR_ATTACK_RANGE,
speed: rc.speed, attack: rc.attack, defense: rc.defense,
attackRange: rc.attackRange || CHAR_ATTACK_RANGE,
hpMax: rc.hpMax, regen: !!rc.regen,
enrage: !!rc.enrage, shieldRatio: rc.shieldRatio || 0,
};
@@ -178,8 +210,15 @@ function visibleMicroChars() {
function _serializeChar(ch) {
return {
cid: ch.id, race: ch.race,
name: ch.name,
level: ch.level, exp: ch.exp, exp_max: ch.exp_max,
hp: ch.hp, hp_max: ch.hpMax,
energy: ch.energy, energy_max: ch.energy_max,
attack: ch.attack, defense: ch.defense, speed: ch.speed,
mood: ch.mood, job: ch.job, status: ch.status,
x: Math.round(ch.x), y: Math.round(ch.y),
facing: ch.facing, state: ch.state,
equip: ch.equip || {},
tile_ref: ch.tile || {},
};
}
@@ -201,18 +240,22 @@ function scheduleSaveChars(delay) {
charSaveTimer = setTimeout(saveMicroCharsNow, delay || 300);
}
// 从后端拉取全部微观角色,填充 microChars(带 oid/tile 映射)
// 从后端拉取全部微观角色(世界生物表),填充 microChars(带 oid/tile 映射)
async function loadMicroChars() {
if (typeof API === 'undefined' || !API.microChars) return;
const res = await API.microChars();
if (!res || !res.ok) return;
const list = res.chars || [];
const list = res.creatures || res.chars || [];
// 本地仍存在的角色(未保存成功/临时态)优先保留,其余用服务器数据重建
const localById = {};
microChars.forEach(ch => { if (ch.oid) localById[ch.oid] = ch; });
microChars = list.map(c => {
const local = localById[c.oid];
const stats = raceStats(c.race || 'human');
const hpMax = (typeof c.hp_max === 'number') ? c.hp_max : (stats.hpMax || 50);
const hp = (typeof c.hp === 'number' && c.hp > 0) ? c.hp : hpMax;
const enMax = (typeof c.energy_max === 'number') ? c.energy_max : (stats.energyMax || 100);
const en = (typeof c.energy === 'number') ? c.energy : enMax;
return {
id: c.cid || ('char_' + Math.random().toString(36).slice(2, 10)),
oid: c.oid, race: c.race || 'human',
@@ -221,13 +264,21 @@ async function loadMicroChars() {
targetX: c.x, targetY: c.y,
speed: stats.speed, selected: false, trail: [],
tile: c.tile_ref || {},
hp: (typeof c.hp === 'number' && c.hp > 0) ? c.hp : stats.hpMax,
hpMax: stats.hpMax,
attack: stats.attack,
// 后端权威数值(训练/装备/战斗后的真实值)
name: c.name || ((RACE_CONFIG[c.race] && RACE_CONFIG[c.race].name) || '居民') + '居民',
level: (typeof c.level === 'number') ? c.level : 1,
exp: (typeof c.exp === 'number') ? c.exp : 0,
exp_max: (typeof c.exp_max === 'number') ? c.exp_max : 100,
hp: hp,
hpMax: hpMax,
energy: en,
energy_max: enMax,
attack: (typeof c.attack === 'number') ? c.attack : (stats.attack || 5),
defense: (typeof c.defense === 'number') ? c.defense : (stats.defense || 3),
attackRange: stats.attackRange,
regen: stats.regen,
enrage: stats.enrage,
shieldMax: stats.shieldRatio ? Math.max(1, Math.round(stats.hpMax * stats.shieldRatio)) : 0,
shieldMax: stats.shieldRatio ? Math.max(1, Math.round(hpMax * stats.shieldRatio)) : 0,
shield: 0,
enrageTriggered: false,
lastHurt: 0,
@@ -238,18 +289,21 @@ async function loadMicroChars() {
wanderInterval: 2 + Math.random() * 4,
_userMove: false,
freeMove: true, // 自由移动开关(默认开;面板按钮可切换)
equip: c.equip || {},
...(local ? { frame: local.frame, frameT: local.frameT } : {}),
};
});
if (selectedCharId && !getChar(selectedCharId)) selectedCharId = null;
window.microChars = microChars;
updateCharPanel();
updateCharPlacePanel();
}
function createChar(race, x, y) {
const id = 'char_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 5);
const stats = raceStats(race);
const rt = RACE_CONFIG[race] || {};
const hpMax = stats.hpMax || 50;
const enMax = stats.energyMax || 100;
const ch = {
id, race, x, y, targetX: x, targetY: y,
state: 'idle', frame: 0, frameT: 0,
@@ -258,24 +312,31 @@ function createChar(race, x, y) {
trail: [],
oid: null,
tile: microCharCurrentTile() || {},
hp: stats.hpMax, // 生命值(满血)
hpMax: stats.hpMax, // 最大生命(种族差异)
attack: stats.attack, // 攻击力(种族差异)
attackRange: stats.attackRange, // 攻击范围(龙族 1.5 倍 = 90px)
regen: stats.regen, // 是否有脱战恢复
enrage: stats.enrage, // 是否狂暴(矮人:掉血加攻)
shieldMax: stats.shieldRatio ? Math.max(1, Math.round(stats.hpMax * stats.shieldRatio)) : 0, // 护盾上限(矮人 10% 最大生命)
shield: 0, // 当前护盾值
enrageTriggered: false, // 护盾是否已触发(血量≤30%)
lastHurt: 0, // 上次受伤时间(ms)
hurtFlash: 0, // 受伤闪白计时
dying: false, // 死亡动画中
deathT: 0, // 死亡动画进度(秒)
name: (rt.name || '居民') + '居民', // 名称(来自后端种族模板)
level: 1, exp: 0, exp_max: 100,
hp: hpMax, // 生命值(满血)
hpMax: hpMax, // 最大生命(种族差异,来自模板)
energy: enMax, // 能量(满)
energy_max: enMax, // 能量上限(种族差异,来自模板)
attack: stats.attack, // 攻击力(种族差异,来自模板)
defense: stats.defense, // 防御力(种族差异,来自模板)
attackRange: stats.attackRange, // 攻击范围(龙族 1.5 倍 = 90px)
regen: stats.regen, // 是否有脱战恢复
enrage: stats.enrage, // 是否狂暴(矮人:掉血加攻)
shieldMax: stats.shieldRatio ? Math.max(1, Math.round(hpMax * stats.shieldRatio)) : 0, // 护盾上限(矮人 10% 最大生命)
shield: 0, // 当前护盾值
enrageTriggered: false, // 护盾是否已触发(血量≤30%)
lastHurt: 0, // 上次受伤时间(ms)
hurtFlash: 0, // 受伤闪白计时
dying: false, // 死亡动画中
deathT: 0, // 死亡动画进度(秒)
mood: '😊', job: 'none', status: 'idle',
equip: {},
// 自主漫游状态
wanderT: Math.random() * 3, // 首次漫游前随机等待
wanderT: Math.random() * 3, // 首次漫游前随机等待
wanderInterval: 2 + Math.random() * 4, // 2~6 秒
_userMove: false, // 是否用户指令移动
freeMove: charPlaceFreeMove, // 自由移动开关(默认按右侧面板"自由移动"按钮)
_userMove: false, // 是否用户指令移动
freeMove: charPlaceFreeMove, // 自由移动开关(默认开)
};
microChars.push(ch);
selectChar(id);
@@ -900,48 +961,8 @@ function updateCharPanel() {
panel.querySelector('#charDelBtn').addEventListener('click', () => deleteChar(ch.id));
}
// ---- 放置面板(右侧建筑面板"角色"分类下) ----
// 新放置角色默认是否自由移动(面板上的自由移动按钮切换此值)
// 新放置角色默认是否自由移动(由选中角色面板上的自由移动按钮间接控制)
let charPlaceFreeMove = true;
function updateCharPlacePanel() {
const panel = document.getElementById('charPlacePanel');
if (!panel) return;
const races = Object.keys(RACE_CONFIG);
const fmBtnStyle = charPlaceFreeMove
? 'background:#1d4a2a;color:#7ef0a0;border:1px solid #3a8a5e;'
: 'background:#3a3a4a;color:#aab;border:1px solid #5a5a7a;';
panel.innerHTML = `
<div style="font-size:12px;color:#8a9aaa;margin-bottom:6px;">放置角色</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
${races.map(r => {
const active = charPlaceRace === r && charPlaceMode;
const style = active
? 'background:#2a4a6a;color:#cde;border:1px solid #5a9acc;'
: 'background:#1d2b4a;color:#cde;border:1px solid #3a5a8e;';
return `<button class="charRaceBtn" data-race="${r}" style="${style}padding:5px 10px;border-radius:5px;cursor:pointer;font-size:12px;">${RACE_CONFIG[r].name}</button>`;
}).join('')}
</div>
<div style="margin-top:8px;display:flex;gap:6px;flex-wrap:wrap;">
<button id="charFreeMoveToggle" style="${fmBtnStyle}padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;">${charPlaceFreeMove ? '⏸ 停止自由移动' : '▶ 自由移动'}</button>
<button id="charClearAll" style="background:#4a1e1e;color:#faa;border:1px solid #8a3a3a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;">取消全部</button>
</div>
`;
panel.querySelectorAll('.charRaceBtn').forEach(btn => {
btn.addEventListener('click', () => {
charPlaceRace = btn.dataset.race;
charPlaceMode = true; // 点击种族直接进入放置模式(简化:无需再点"开始放置")
updateCharPlacePanel();
});
});
panel.querySelector('#charFreeMoveToggle').addEventListener('click', () => {
charPlaceFreeMove = !charPlaceFreeMove;
updateCharPlacePanel();
});
panel.querySelector('#charClearAll').addEventListener('click', () => {
clearChars();
updateCharPlacePanel();
});
}
// 导出到 window
window.createChar = createChar;
@@ -955,7 +976,6 @@ window.renderChars = renderChars;
window.hitChar = hitChar;
window.clampCharToIsland = clampCharToIsland;
window.updateCharPanel = updateCharPanel;
window.updateCharPlacePanel = updateCharPlacePanel;
window.allSheetsLoaded = allSheetsLoaded;
window.loadCharSheet = loadCharSheet;
window.preloadAllSheets = preloadAllSheets;
+43 -10
View File
@@ -230,6 +230,41 @@ const API = {
return await r.json();
} catch (e) { return null; }
},
async races() {
try {
const r = await fetch(_worldUrl('/yt_world/api/races'), { credentials: _wcred });
if (!r.ok) return { ok: false, error: 'http_' + r.status };
const j = await r.json();
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
} catch (e) { return { ok: false, error: 'network' }; }
},
async equipCatalog() {
try {
const r = await fetch(_worldUrl('/yt_world/api/equip/catalog'), { credentials: _wcred });
if (!r.ok) return { ok: false, error: 'http_' + r.status };
const j = await r.json();
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
} catch (e) { return { ok: false, error: 'network' }; }
},
async creature(cid) {
try {
const r = await fetch(_worldUrl('/yt_world/api/creature/' + encodeURIComponent(cid)), { credentials: _wcred });
if (!r.ok) return { ok: false, error: 'http_' + r.status };
const j = await r.json();
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
} catch (e) { return { ok: false, error: 'network' }; }
},
async creatureAction(payload) {
try {
const r = await fetch(_worldUrl('/yt_world/api/creature/action'), {
method: 'POST', credentials: _wcred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload || {}),
});
const j = await r.json();
return j && j.ok === false ? j : (j || { ok: true });
} catch (e) { return { ok: false, error: 'network' }; }
},
async buildingAction(payload) {
try {
const r = await fetch(_worldUrl('/yt_world/api/building/action'), {
@@ -244,22 +279,16 @@ const API = {
}
},
// ---------- 微观世界角色(玩家放置的种族角色,绑定用户持久化) ----------
// 以下两个已重定向到世界生物表(yt.world.creature),保留旧名以兼容 characters.js
async microChars() {
try {
const r = await fetch(_worldUrl('/yt_world/api/micro/chars'), { credentials: _wcred });
if (!r.ok) return { ok: false, error: 'http_' + r.status };
const j = await r.json();
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
} catch (e) {
return { ok: false, error: 'network', detail: String(e && e.message || e) };
}
return this.creatures();
},
async saveMicroChars(chars, deleted) {
try {
const r = await fetch(_worldUrl('/yt_world/api/micro/chars/save'), {
const r = await fetch(_worldUrl('/yt_world/api/creatures/save'), {
method: 'POST', credentials: _wcred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chars: chars || [], deleted: deleted || [] }),
body: JSON.stringify({ creatures: chars || [], deleted: deleted || [] }),
});
const j = await r.json();
return j && j.ok === false ? j : (j || { ok: true });
@@ -267,6 +296,10 @@ const API = {
return { ok: false, error: 'network', detail: String(e && e.message || e) };
}
},
// 以下旧名重定向到世界生物表(yt.world.creature),保留以兼容历史调用
async microEquipCatalog() { return this.equipCatalog(); },
async microChar(cid) { return this.creature(cid); },
async microCharAction(payload) { return this.creatureAction(payload); },
// ---------- 图鉴(codex) ----------
async codexResources() {
return (await fetch(_worldUrl('/yt_world/api/codex/resources'), { credentials: _wcred })).json();