角色面板
This commit is contained in:
+99
-79
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user