1027 lines
42 KiB
JavaScript
1027 lines
42 KiB
JavaScript
// assets/characters.js — 微观世界角色动画系统(6种族×5动作)
|
||
'use strict';
|
||
|
||
// ---- 种族精灵表与数值(单一事实来源 = 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, defense: rc.defense,
|
||
attackRange: rc.attackRange || CHAR_ATTACK_RANGE,
|
||
hpMax: rc.hpMax, regen: !!rc.regen,
|
||
enrage: !!rc.enrage, shieldRatio: rc.shieldRatio || 0,
|
||
};
|
||
}
|
||
|
||
// 当前实际攻击力(含矮人狂暴加成)
|
||
function currentAttackOf(ch) {
|
||
let dmg = ch.attack || CHAR_ATTACK_DAMAGE;
|
||
if (ch.enrage) {
|
||
const lost = 1 - (ch.hp || 0) / (ch.hpMax || CHAR_HP_MAX);
|
||
const blocks = Math.max(0, Math.floor(lost * 10)); // 每损失 10% 为一块
|
||
dmg = ch.attack * (1 + 0.05 * blocks);
|
||
}
|
||
return Math.round(dmg * 100) / 100;
|
||
}
|
||
|
||
// ---- 角色状态机(每张1×6横条的帧分配)----
|
||
const CHAR_STATES = {
|
||
idle: { sheet: 'idle_attack', base: 0, count: 3, loop: true, dur: 0.30 }, // 0-2
|
||
walk: { sheet: 'walk', base: 0, count: 6, loop: true, dur: 0.11 }, // 0-5
|
||
attack: { sheet: 'idle_attack', base: 3, count: 3, loop: false, dur: 0.10 }, // 3-5
|
||
work: { sheet: 'work_comm', base: 0, count: 3, loop: false, dur: 0.11 }, // 0-2
|
||
communicate: { sheet: 'work_comm', base: 3, count: 3, loop: false, dur: 0.13 }, // 3-5
|
||
};
|
||
const CHAR_CN = { idle:'待机', walk:'行走', attack:'攻击', work:'工作', communicate:'交流' };
|
||
|
||
const CHAR_FW = 48, CHAR_FH = 64;
|
||
const CHAR_HP_MAX = 50; // 角色初始生命值
|
||
const CHAR_ATTACK_RANGE = 60; // 攻击范围(像素),范围内其他角色扣血
|
||
const CHAR_ATTACK_DAMAGE = 5; // 每次攻击伤害
|
||
const CHAR_DEATH_DUR = 1.2; // 死亡动画时长(倒地+渐隐,秒)
|
||
|
||
// ---- 精灵表缓存 ----
|
||
const charSheets = {};
|
||
function loadCharSheet(race, key) {
|
||
const cacheKey = race + '_' + key;
|
||
if (charSheets[cacheKey]) return charSheets[cacheKey];
|
||
const cfg = RACE_CONFIG[race];
|
||
if (!cfg || !cfg[key]) return null;
|
||
const img = new Image();
|
||
img.src = cfg[key];
|
||
charSheets[cacheKey] = img;
|
||
return img;
|
||
}
|
||
function allSheetsLoaded() {
|
||
return Object.values(charSheets).every(img => img && img.complete && img.naturalWidth > 0);
|
||
}
|
||
function preloadAllSheets() {
|
||
Object.keys(RACE_CONFIG).forEach(race => {
|
||
['walk','idle_attack','work_comm'].forEach(k => loadCharSheet(race, k));
|
||
});
|
||
}
|
||
|
||
// ---- 角色管理 ----
|
||
let microChars = [];
|
||
let selectedCharId = null;
|
||
let charPlaceMode = false; // 放置模式
|
||
let charPlaceRace = 'human'; // 当前要放置的种族
|
||
|
||
// ---- 数据库同步(角色绑定用户世界,刷新不丢)----
|
||
let charSaveTimer = null;
|
||
let charDeletedCids = []; // 待删除的 cid(已从本地数组移除,等后端确认)
|
||
|
||
// ---- 障碍避让(由 world.html 注册:返回建筑占用检测 + 目标点吸附)----
|
||
let _charAvoid = null; // { isBlocked(wx, wy): bool, findClear(wx, wy): {x,y} }
|
||
function setCharAvoid(handler) {
|
||
_charAvoid = handler || null;
|
||
}
|
||
function isCharPosBlocked(wx, wy) {
|
||
return !!( _charAvoid && _charAvoid.isBlocked(wx, wy) );
|
||
}
|
||
|
||
// 岛内边界(保留 12% buffer,远离岛边)
|
||
function isPosInIsland(wx, wy) {
|
||
if (typeof microCanvas === 'undefined' || !microCanvas) return true;
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const cx = mW / 2, cy = mH / 2;
|
||
const Rx = mW * 0.50, Ry = mH * 0.43;
|
||
const dx = wx - cx, dy = wy - cy;
|
||
return Math.hypot(dx / Rx, dy / Ry) <= 0.85;
|
||
}
|
||
// 把任意点投影到岛内(��留 85% buffer)
|
||
function projectToIsland(wx, wy) {
|
||
if (typeof microCanvas === 'undefined' || !microCanvas) return { x: wx, y: wy };
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const cx = mW / 2, cy = mH / 2;
|
||
const Rx = mW * 0.50, Ry = mH * 0.43;
|
||
const dx = wx - cx, dy = wy - cy;
|
||
const d = Math.hypot(dx / Rx, dy / Ry);
|
||
if (d > 0.85) {
|
||
const k = 0.85 / d;
|
||
return { x: cx + dx * k, y: cy + dy * k };
|
||
}
|
||
return { x: wx, y: wy };
|
||
}
|
||
function findCharClearTarget(tx, ty) {
|
||
if (!_charAvoid || !_charAvoid.findClear) return { x: tx, y: ty };
|
||
let adj = _charAvoid.findClear(tx, ty) || { x: tx, y: ty };
|
||
// 若吸附点还在岛外/建筑上,强制投影到岛内
|
||
if (!isPosInIsland(adj.x, adj.y)) adj = projectToIsland(adj.x, adj.y);
|
||
return adj;
|
||
}
|
||
|
||
// 漫游意图生成(不是纯随机——有"想走走"、"想逛逛"、"想朝中心"等目标)
|
||
function generateWanderTarget(ch) {
|
||
const r = Math.random();
|
||
for (let attempt = 0; attempt < 12; attempt++) {
|
||
let tx, ty;
|
||
if (r < 0.55) {
|
||
// 闲逛:就近 60~180px 随机方向
|
||
const ang = Math.random() * Math.PI * 2;
|
||
const dist = 60 + Math.random() * 120;
|
||
tx = ch.x + Math.cos(ang) * dist;
|
||
ty = ch.y + Math.sin(ang) * dist;
|
||
} else if (r < 0.85) {
|
||
// 朝地图中心(让贴边角色主动回到中央,避免卡岛边)
|
||
const mW = microCanvas.width, mH = microCanvas.height;
|
||
const cx = mW / 2, cy = mH / 2;
|
||
const dx = cx - ch.x, dy = cy - ch.y;
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist > 60) {
|
||
tx = ch.x + (dx / dist) * 130;
|
||
ty = ch.y + (dy / dist) * 130;
|
||
} else {
|
||
tx = ch.x; ty = ch.y;
|
||
}
|
||
} else {
|
||
// 远行:180~320px 长距离(让漫游更有变化,避免一直小范围打转)
|
||
const ang = Math.random() * Math.PI * 2;
|
||
const dist = 180 + Math.random() * 140;
|
||
tx = ch.x + Math.cos(ang) * dist;
|
||
ty = ch.y + Math.sin(ang) * dist;
|
||
}
|
||
if (isPosInIsland(tx, ty) && !isCharPosBlocked(tx, ty)) return { tx, ty };
|
||
}
|
||
return null; // 找不到合适位置,本轮跳过漫游
|
||
}
|
||
|
||
// 当前微观地块(读 world.html 的 microState)
|
||
function microCharCurrentTile() {
|
||
if (typeof microState !== 'undefined' && microState && microState.tile) {
|
||
return { q: microState.tile.q, r: microState.tile.r };
|
||
}
|
||
return null;
|
||
}
|
||
function charTileMatches(ch, tile) {
|
||
if (!tile) return true;
|
||
const t = ch.tile || {};
|
||
return t.q === tile.q && t.r === tile.r;
|
||
}
|
||
// 只返回"当前地块"的角色(跨地块角色不渲染/不点击)
|
||
function visibleMicroChars() {
|
||
const tile = microCharCurrentTile();
|
||
return microChars.filter(ch => charTileMatches(ch, tile));
|
||
}
|
||
|
||
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 || {},
|
||
};
|
||
}
|
||
async function saveMicroCharsNow() {
|
||
if (charSaveTimer) { clearTimeout(charSaveTimer); charSaveTimer = null; }
|
||
if (typeof API === 'undefined' || !API.saveMicroChars) return;
|
||
const chars = microChars.map(_serializeChar);
|
||
const deleted = charDeletedCids.slice();
|
||
charDeletedCids = [];
|
||
if (!chars.length && !deleted.length) return;
|
||
const res = await API.saveMicroChars(chars, deleted);
|
||
// 失败:deleted 收回,下轮重试(chars 反正每次全量发)
|
||
if (!res || !res.ok) {
|
||
charDeletedCids = deleted.concat(charDeletedCids);
|
||
}
|
||
}
|
||
function scheduleSaveChars(delay) {
|
||
if (charSaveTimer) clearTimeout(charSaveTimer);
|
||
charSaveTimer = setTimeout(saveMicroCharsNow, delay || 300);
|
||
}
|
||
|
||
// 从后端拉取全部微观角色(世界生物表),填充 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.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',
|
||
x: c.x, y: c.y, facing: c.facing || 1,
|
||
state: c.state || 'idle', frame: 0, frameT: 0,
|
||
targetX: c.x, targetY: c.y,
|
||
speed: stats.speed, selected: false, trail: [],
|
||
tile: c.tile_ref || {},
|
||
// 后端权威数值(训练/装备/战斗后的真实值)
|
||
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(hpMax * stats.shieldRatio)) : 0,
|
||
shield: 0,
|
||
enrageTriggered: false,
|
||
lastHurt: 0,
|
||
hurtFlash: 0,
|
||
dying: false,
|
||
deathT: 0,
|
||
wanderT: Math.random() * 3,
|
||
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();
|
||
}
|
||
|
||
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,
|
||
facing: 1, speed: stats.speed,
|
||
selected: false, placed: true,
|
||
trail: [],
|
||
oid: null,
|
||
tile: microCharCurrentTile() || {},
|
||
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, // 首次漫游前随机等待
|
||
wanderInterval: 2 + Math.random() * 4, // 2~6 秒
|
||
_userMove: false, // 是否用户指令移动
|
||
freeMove: charPlaceFreeMove, // 自由移动开关(默认开)
|
||
};
|
||
microChars.push(ch);
|
||
selectChar(id);
|
||
scheduleSaveChars(100);
|
||
// 任务:首位居民(ch4_char)。createChar 是种族角色唯一创建入口,
|
||
// 直接发 char_recruited 推进任务(不经由 microState.units,避免与角色 AI 渲染叠加)。
|
||
if (typeof emitTaskEvent === 'function') emitTaskEvent('char_recruited', { race: race });
|
||
return ch;
|
||
}
|
||
function getChar(id) { return microChars.find(c => c.id === id); }
|
||
function selectChar(id) {
|
||
microChars.forEach(c => {
|
||
c.selected = (c.id === id);
|
||
// 新选中的角色:若正在自主漫游(非用户指令),立即停下待命
|
||
if (c.selected && !c._userMove && c.state === 'walk') {
|
||
c.state = 'idle';
|
||
c.frame = 0;
|
||
c.frameT = 0;
|
||
c.wanderT = 0;
|
||
}
|
||
});
|
||
selectedCharId = id || null;
|
||
updateCharPanel();
|
||
}
|
||
function deleteChar(id) {
|
||
const ch = getChar(id);
|
||
if (ch) charDeletedCids.push(ch.id);
|
||
microChars = microChars.filter(c => c.id !== id);
|
||
window.microChars = microChars;
|
||
if (selectedCharId === id) { selectedCharId = null; updateCharPanel(); }
|
||
scheduleSaveChars(100);
|
||
}
|
||
function clearChars() {
|
||
microChars.forEach(ch => charDeletedCids.push(ch.id));
|
||
microChars = [];
|
||
window.microChars = microChars;
|
||
selectedCharId = null;
|
||
updateCharPanel();
|
||
scheduleSaveChars(100);
|
||
}
|
||
|
||
// ---- 动作触发 ----
|
||
function triggerCharAction(charId, action) {
|
||
const ch = getChar(charId);
|
||
if (!ch || !CHAR_STATES[action]) return false;
|
||
ch.state = action;
|
||
ch.frame = 0;
|
||
ch.frameT = 0;
|
||
if (action === 'attack') applyAttackDamage(ch); // 攻击:范围内其他角色扣血
|
||
scheduleSaveChars(200);
|
||
return true;
|
||
}
|
||
|
||
// 攻击:范围内(CHAR_ATTACK_RANGE)其他角色扣生命值,HP<=0 的进入倒地
|
||
function applyAttackDamage(attacker) {
|
||
const now = Date.now();
|
||
const damage = currentAttackOf(attacker); // 攻击力(含矮人狂暴加成)
|
||
const range = attacker.attackRange || CHAR_ATTACK_RANGE; // 攻击范围(龙族 1.5 倍 = 90px)
|
||
const fmt = (v) => (v % 1 === 0 ? String(v) : String(parseFloat(v.toFixed(2)))); // 显示去尾零
|
||
const vis = visibleMicroChars();
|
||
for (const other of vis) {
|
||
if (other.id === attacker.id) continue;
|
||
const d = Math.hypot(other.x - attacker.x, other.y - attacker.y);
|
||
if (d <= range) {
|
||
// 防御减免:实际伤害 = max(0, 攻击力 - 防御力);攻击 ≤ 防御则免伤(命中但不掉血)
|
||
let dmg = Math.max(0, damage - (other.defense || 0));
|
||
// 护盾优先吸收(矮人铁壁)
|
||
if ((other.shield || 0) > 0 && dmg > 0) {
|
||
const absorbed = Math.min(other.shield, dmg);
|
||
other.shield = Math.max(0, other.shield - absorbed);
|
||
dmg = Math.max(0, dmg - absorbed);
|
||
if (absorbed > 0) {
|
||
if (!other.dmgPopups) other.dmgPopups = [];
|
||
other.dmgPopups.push({ value: fmt(absorbed), t: 0, shield: true }); // 青色护盾吸收
|
||
}
|
||
}
|
||
// 剩余伤害扣血(钳制在 [0, 有效生命上限] 内,防止脱装备后上限回落导致越界)
|
||
if (!other.dmgPopups) other.dmgPopups = [];
|
||
if (dmg > 0) {
|
||
other.hp = Math.min(other.hpMax, Math.max(0, (typeof other.hp === 'number' ? other.hp : (other.hpMax || CHAR_HP_MAX)) - dmg));
|
||
other.hurtFlash = 0.4; // 受伤闪白
|
||
other.lastHurt = now; // 记录受伤时间(脱战恢复计时用)
|
||
other.dmgPopups.push({ value: fmt(dmg), t: 0 }); // 红色扣血
|
||
} else {
|
||
// 0 伤害也要显示:命中但被防御/护盾完全减免(免伤)
|
||
other.dmgPopups.push({ value: '0', t: 0, blocked: true }); // 灰色免伤飘字
|
||
}
|
||
// 矮人铁壁:血量首次 ≤30% 时获得 10% 最大生命的护盾
|
||
if (other.enrage && !other.enrageTriggered && other.hp > 0 && other.hp <= other.hpMax * 0.3) {
|
||
other.enrageTriggered = true;
|
||
other.shield = other.shieldMax;
|
||
if (!other.healPopups) other.healPopups = [];
|
||
other.healPopups.push({ value: other.shieldMax, t: 0, shield: true }); // 青色护盾获得
|
||
}
|
||
}
|
||
}
|
||
// 处理 HP<=0 的角色:进入倒地死亡动画(不立即删除;动画结束后在 updateChars 移除)
|
||
microChars.forEach(c => {
|
||
if ((typeof c.hp === 'number' ? c.hp : 0) <= 0 && !c.dying) {
|
||
c.dying = true; // 死亡动画开始
|
||
c.deathT = 0;
|
||
c.state = 'idle';
|
||
c.hurtFlash = 0;
|
||
c.dmgPopups = [];
|
||
c.trail = [];
|
||
}
|
||
});
|
||
if (typeof toast === 'function') toast('⚔️ 有角色生命耗尽!');
|
||
}
|
||
function setCharTarget(charId, tx, ty) {
|
||
const ch = getChar(charId);
|
||
if (!ch) return false;
|
||
const adj = findCharClearTarget(tx, ty); // 若目标落在建筑上则吸附到最近空地
|
||
ch.targetX = adj.x;
|
||
ch.targetY = adj.y;
|
||
ch.state = 'walk';
|
||
ch.frame = 0;
|
||
ch.frameT = 0;
|
||
ch.trail = [];
|
||
ch.speed = raceStats(ch.race).speed; // 用户指令:按种族移速
|
||
ch._userMove = true;
|
||
scheduleSaveChars(200);
|
||
return true;
|
||
}
|
||
|
||
// ---- 更新角色(每帧)----
|
||
|
||
// WASD 八方向:返回归一化方向向量 {x,y};无按键或相反键抵消时返回 null
|
||
function wasdDirection() {
|
||
const k = (typeof window.wasdKeys !== 'undefined') ? window.wasdKeys : null;
|
||
if (!k) return null;
|
||
let x = 0, y = 0;
|
||
if (k.w) y -= 1; // 上
|
||
if (k.s) y += 1; // 下
|
||
if (k.a) x -= 1; // 左
|
||
if (k.d) x += 1; // 右
|
||
if (x === 0 && y === 0) return null; // 无按键 或 相反键抵消(W+S / A+D)
|
||
const len = Math.hypot(x, y); // 斜向归一化(对角线 0.707)
|
||
return { x: x / len, y: y / len };
|
||
}
|
||
|
||
function updateChars(dt) {
|
||
const deadChars = []; // 死亡动画播放完的角色,循环结束后统一移除
|
||
visibleMicroChars().forEach(ch => {
|
||
const st = CHAR_STATES[ch.state];
|
||
|
||
// 死亡动画:计时推进,结束则移除(标记删除同步数据库);死亡期间冻结其他逻辑
|
||
if (ch.dying) {
|
||
ch.deathT = (ch.deathT || 0) + dt;
|
||
if (ch.deathT >= CHAR_DEATH_DUR) {
|
||
charDeletedCids.push(ch.id);
|
||
deadChars.push(ch.id);
|
||
}
|
||
return;
|
||
}
|
||
|
||
// 宝箱目标失效(被其他角色开启或切地块清空):重置为 idle
|
||
if (ch._chestTarget && (!window.microChests || !window.microChests.includes(ch._chestTarget))) {
|
||
ch._chestTarget = null;
|
||
if (ch.state === 'walk') {
|
||
ch.state = 'idle'; ch.frame = 0; ch.frameT = 0;
|
||
ch.wanderT = 0; ch._userMove = false;
|
||
}
|
||
}
|
||
|
||
// 玩家移动控制已移除(含 WASD 键盘):kbdDir 恒为 null,生物只由 AI/漫游/宝箱逻辑驱动
|
||
const kbdDir = null;
|
||
if (kbdDir) {
|
||
// 键盘移动恒用种族基础移速(不受漫游/点击移动改过的 ch.speed 残留影响)
|
||
const kbdSpeed = raceStats(ch.race).speed;
|
||
ch.x += kbdDir.x * kbdSpeed * dt;
|
||
ch.y += kbdDir.y * kbdSpeed * dt;
|
||
clampCharToIsland(ch);
|
||
if (kbdDir.x < -0.1) ch.facing = 1;
|
||
else if (kbdDir.x > 0.1) ch.facing = -1;
|
||
// 目标点同步为当前位置:松开按键后自然停下(不回头走旧目标)
|
||
ch.targetX = ch.x;
|
||
ch.targetY = ch.y;
|
||
ch.state = 'walk';
|
||
ch.speed = kbdSpeed; // 同步速度,避免松开后行走逻辑用残留慢速
|
||
ch.wanderT = 0;
|
||
ch._userMove = true;
|
||
ch.trail.push({ x: ch.x, y: ch.y });
|
||
if (ch.trail.length > 50) ch.trail.shift();
|
||
} else if (ch.state === 'walk' && ch.selected && !ch._userMove) {
|
||
// 选中角色松开键盘:停住待命(不继续走旧目标)
|
||
ch.state = 'idle';
|
||
ch.frame = 0; ch.frameT = 0;
|
||
ch.targetX = ch.x;
|
||
ch.targetY = ch.y;
|
||
} else {
|
||
// 原有漫游 / 行走逻辑
|
||
// 自由移动开关:ch.freeMove !== false 才允许自主漫游(默认开,可被面板按钮关闭)
|
||
if (ch.freeMove !== false && !ch.selected && ch.state === 'idle') {
|
||
// 宝箱吸引:附近存在活跃宝箱时优先去开启(测试阶段吸引半径全屏)
|
||
const chests = window.microChests;
|
||
const c2s = window.microCellToScreen;
|
||
let best = null, bestD = Infinity;
|
||
if (chests && c2s && chests.length) {
|
||
for (const chest of chests) {
|
||
if (chest._claiming) continue;
|
||
const sp = c2s(chest.cell_i + 0.5, chest.cell_j + 0.5);
|
||
const d = Math.hypot(sp.x - ch.x, sp.y - ch.y);
|
||
if (d < bestD) { bestD = d; best = { chest, sp }; }
|
||
}
|
||
}
|
||
if (best && bestD < 2000) { // 吸引半径:2000px 已覆盖整个浮岛视图
|
||
ch._chestTarget = best.chest;
|
||
ch.targetX = best.sp.x;
|
||
ch.targetY = best.sp.y;
|
||
ch.state = 'walk';
|
||
ch.frame = 0; ch.frameT = 0;
|
||
ch.speed = 90 + Math.random() * 30;
|
||
ch._userMove = false;
|
||
ch.wanderT = 0;
|
||
ch.trail = [];
|
||
} else {
|
||
ch.wanderT = (ch.wanderT || 0) + dt;
|
||
const interval = ch.wanderInterval || (2 + Math.random() * 4);
|
||
if (ch.wanderT >= interval) {
|
||
ch.wanderT = 0;
|
||
ch.wanderInterval = 2 + Math.random() * 4;
|
||
// 多意图漫游目标:闲逛 / 朝中心 / 远行(带岛内+建筑校验)
|
||
const target = generateWanderTarget(ch);
|
||
if (!target) {
|
||
ch.wanderInterval = 1 + Math.random() * 2; // 找不到就短等重试
|
||
return;
|
||
}
|
||
ch.targetX = target.tx;
|
||
ch.targetY = target.ty;
|
||
ch.state = 'walk';
|
||
ch.frame = 0;
|
||
ch.frameT = 0;
|
||
ch.speed = 35 + Math.random() * 20; // 慢速:约全速的一半
|
||
ch._userMove = false;
|
||
ch.trail = [];
|
||
}
|
||
}
|
||
}
|
||
// 行走逻辑(else 块的直接子块:键盘移动激活时不会执行)
|
||
if (ch.state === 'walk') {
|
||
const dx = ch.targetX - ch.x, dy = ch.targetY - ch.y;
|
||
const dist = Math.hypot(dx, dy);
|
||
if (dist < 3 || (ch._chestTarget && dist < 35)) {
|
||
// 宝箱目标到达:调用 world.html 的开启逻辑(飘字+发奖+移除宝箱)
|
||
if (ch._chestTarget && window.microClaimChest) {
|
||
window.microClaimChest(ch._chestTarget, ch);
|
||
}
|
||
ch._chestTarget = null;
|
||
ch.state = 'idle';
|
||
ch.frame = 0;
|
||
ch.frameT = 0;
|
||
ch.wanderT = 0; // 到达后重新计时漫游
|
||
ch._userMove = false;
|
||
scheduleSaveChars(150); // 坐标落库
|
||
} else {
|
||
const ux = dx / dist, uy = dy / dist;
|
||
const stepX = ux * ch.speed * dt;
|
||
const stepY = uy * ch.speed * dt;
|
||
const nextX = ch.x + stepX;
|
||
const nextY = ch.y + stepY;
|
||
if (!isCharPosBlocked(nextX, nextY)) {
|
||
// 前方通畅:正常直线前进
|
||
ch.x = nextX;
|
||
ch.y = nextY;
|
||
if (dx < -1) ch.facing = 1;
|
||
else if (dx > 1) ch.facing = -1;
|
||
ch.trail.push({x: ch.x, y: ch.y});
|
||
if (ch.trail.length > 50) ch.trail.shift();
|
||
} else {
|
||
// 直线被建筑挡:沿墙滑行(优先选离目标更近的一侧),绕过建筑后自动回到直线
|
||
const slide = ch.speed * dt;
|
||
const nX = -uy, nY = ux; // 垂直方向(法线)
|
||
const dL = Math.hypot(ch.x + nX * slide - ch.targetX, ch.y + nY * slide - ch.targetY);
|
||
const dR = Math.hypot(ch.x - nX * slide - ch.targetX, ch.y - nY * slide - ch.targetY);
|
||
const dirs = dL <= dR ? [[nX, nY], [-nX, -nY]] : [[-nX, -nY], [nX, nY]];
|
||
let slid = false;
|
||
for (const [sx, sy] of dirs) {
|
||
const cx2 = ch.x + sx * slide;
|
||
const cy2 = ch.y + sy * slide;
|
||
if (!isCharPosBlocked(cx2, cy2)) {
|
||
ch.x = cx2;
|
||
ch.y = cy2;
|
||
if (sx > 0.1) ch.facing = -1;
|
||
else if (sx < -0.1) ch.facing = 1;
|
||
ch.trail.push({x: ch.x, y: ch.y});
|
||
if (ch.trail.length > 50) ch.trail.shift();
|
||
slid = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!slid) {
|
||
// 两侧都被挡(死角):停下,等漫游换方向
|
||
ch.state = 'idle';
|
||
ch.frame = 0;
|
||
ch.frameT = 0;
|
||
ch.wanderT = 0;
|
||
ch._userMove = false;
|
||
scheduleSaveChars(150);
|
||
}
|
||
}
|
||
}
|
||
// 位置钳制在浮岛椭圆内(防止角色走到地图外面)
|
||
clampCharToIsland(ch);
|
||
// 卡死检测:行走状态但位置几乎没动(被建筑/岛边卡住),强制换目标
|
||
// 阈值按"期望移动量"的比例算——慢速种族(矮人72/漫游35)每帧位移小,固定阈值会误判
|
||
if (ch.state === 'walk') {
|
||
const prevX = ch.prevX ?? ch.x;
|
||
const prevY = ch.prevY ?? ch.y;
|
||
const moved = Math.hypot(ch.x - prevX, ch.y - prevY);
|
||
const expected = Math.max(0.2, ch.speed * dt); // 本帧期望移动量(下限防除零)
|
||
if (moved < expected * 0.3) ch.stallFrames = (ch.stallFrames || 0) + 1; // 实际 < 期望 30% 才算卡
|
||
else ch.stallFrames = 0;
|
||
if (ch.stallFrames > 45) { // 约 0.75 秒没移动
|
||
ch.state = 'idle';
|
||
ch.frame = 0; ch.frameT = 0;
|
||
ch.wanderT = 0;
|
||
ch._userMove = false;
|
||
ch.stallFrames = 0;
|
||
}
|
||
ch.prevX = ch.x;
|
||
ch.prevY = ch.y;
|
||
} else {
|
||
ch.stallFrames = 0;
|
||
}
|
||
}
|
||
}
|
||
// ---- 状态无关的每帧逻辑(行走/待机/漫游均执行)----
|
||
// 受伤闪白计时递减
|
||
if (ch.hurtFlash > 0) ch.hurtFlash = Math.max(0, ch.hurtFlash - dt);
|
||
// 扣血飘字计时推进并清理过期
|
||
if (ch.dmgPopups && ch.dmgPopups.length) {
|
||
for (const p of ch.dmgPopups) p.t += dt;
|
||
ch.dmgPopups = ch.dmgPopups.filter(p => p.t < 0.9);
|
||
}
|
||
// 恢复飘字计时推进并清理过期
|
||
if (ch.healPopups && ch.healPopups.length) {
|
||
for (const p of ch.healPopups) p.t += dt;
|
||
ch.healPopups = ch.healPopups.filter(p => p.t < 0.9);
|
||
}
|
||
// 脱战恢复:不死族,10 秒未受伤后每秒恢复 5% 最大生命,回满为止
|
||
if (ch.regen && ch.hp > 0 && ch.hp < ch.hpMax) {
|
||
const nowMs = Date.now();
|
||
if (!ch.lastHurt || nowMs - ch.lastHurt > 10000) {
|
||
ch.regenAcc = (ch.regenAcc || 0) + dt; // 累积计时,满 1 秒恢复一次
|
||
if (ch.regenAcc >= 1) {
|
||
ch.regenAcc -= 1;
|
||
const heal = Math.max(1, Math.round(ch.hpMax * 0.05)); // 每次回 5%(不死 100 → +5)
|
||
ch.hp = Math.min(ch.hpMax, ch.hp + heal);
|
||
if (!ch.healPopups) ch.healPopups = [];
|
||
ch.healPopups.push({ value: heal, t: 0 });
|
||
scheduleSaveChars(800);
|
||
}
|
||
}
|
||
}
|
||
// 动画帧推进
|
||
ch.frameT += dt;
|
||
if (ch.frameT >= st.dur) {
|
||
ch.frameT = 0;
|
||
if (st.loop) {
|
||
ch.frame = (ch.frame + 1) % st.count;
|
||
} else if (ch.frame >= st.count - 1) {
|
||
ch.state = 'idle';
|
||
ch.frame = 0;
|
||
} else {
|
||
ch.frame++;
|
||
}
|
||
}
|
||
});
|
||
// 死亡动画结束:从数组移除
|
||
if (deadChars.length) {
|
||
microChars = microChars.filter(c => !deadChars.includes(c.id));
|
||
window.microChars = microChars;
|
||
if (selectedCharId && !getChar(selectedCharId)) {
|
||
selectedCharId = null;
|
||
updateCharPanel();
|
||
}
|
||
scheduleSaveChars(150); // 删除落库
|
||
}
|
||
// 选中角色属性变化(hp/护盾)时刷新右侧面板
|
||
if (selectedCharId) {
|
||
const sel = getChar(selectedCharId);
|
||
if (sel && (sel.hp !== sel._panelHp || sel.shield !== sel._panelShield)) {
|
||
sel._panelHp = sel.hp;
|
||
sel._panelShield = sel.shield;
|
||
updateCharPanel();
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 渲染角色 ----
|
||
function renderChars(ctx, time, mW, mH) {
|
||
visibleMicroChars().forEach(ch => {
|
||
const st = CHAR_STATES[ch.state];
|
||
|
||
// 死亡动画:两阶段——先倒地(前50%,旋转90°、不透明),再消失(后50%,躺平渐隐)
|
||
if (ch.dying) {
|
||
const p = Math.min(1, (ch.deathT || 0) / CHAR_DEATH_DUR);
|
||
const fallP = Math.min(1, p / 0.5); // 倒地进度:前 50% 完成 0→90° 旋转
|
||
const fadeP = Math.max(0, (p - 0.5) / 0.5); // 淡出进度:后 50% alpha 1→0
|
||
// 倒地阴影(随倒下变横向)
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.28;
|
||
ctx.fillStyle = '#000';
|
||
ctx.beginPath();
|
||
ctx.ellipse(ch.x, ch.y + 2, 20 + 10 * fallP, 5 - 2 * fallP, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
// 倒地旋转精灵(倒地阶段清晰,淡出阶段渐隐)
|
||
const img = loadCharSheet(ch.race, 'idle_attack');
|
||
if (img && img.complete && img.naturalWidth > 0) {
|
||
const fi = CHAR_STATES.idle.base + (Math.floor(ch.frame || 0) % 3);
|
||
ctx.save();
|
||
ctx.translate(ch.x, ch.y);
|
||
ctx.rotate(fallP * Math.PI / 2); // 前 50% 旋转到底,后 50% 保持躺平
|
||
ctx.globalAlpha = 1 - fadeP; // 后 50% 渐隐
|
||
ctx.drawImage(img, fi * CHAR_FW, 0, CHAR_FW, CHAR_FH, -CHAR_FW / 2, -CHAR_FH * 0.9, CHAR_FW, CHAR_FH);
|
||
ctx.restore();
|
||
}
|
||
return; // 跳过正常渲染
|
||
}
|
||
|
||
const img = loadCharSheet(ch.race, st.sheet);
|
||
if (!img || !img.complete || img.naturalWidth === 0) return;
|
||
const fi = st.base + ch.frame;
|
||
const drawX = ch.x - CHAR_FW * 0.5;
|
||
const drawY = ch.y - CHAR_FH * 0.90;
|
||
|
||
// 路径拖尾(仅选中角色)
|
||
if (ch.selected && ch.trail.length > 1) {
|
||
ctx.save();
|
||
ctx.strokeStyle = 'rgba(125,211,252,0.22)';
|
||
ctx.lineWidth = 2;
|
||
ctx.beginPath();
|
||
ctx.moveTo(ch.trail[0].x, ch.trail[0].y);
|
||
for (let i = 1; i < ch.trail.length; i++) ctx.lineTo(ch.trail[i].x, ch.trail[i].y);
|
||
ctx.stroke();
|
||
ctx.restore();
|
||
}
|
||
|
||
// 选中高亮光环
|
||
if (ch.selected) {
|
||
ctx.save();
|
||
ctx.globalAlpha = 0.25 + 0.15 * Math.sin(time * 3.5);
|
||
ctx.fillStyle = '#4af';
|
||
ctx.beginPath();
|
||
ctx.arc(ch.x, ch.y, 22, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
}
|
||
|
||
// 底圈阴影
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(0,0,0,0.35)';
|
||
ctx.beginPath();
|
||
ctx.ellipse(ch.x, ch.y + 2, 14, 5, 0, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
|
||
// 角色精灵
|
||
ctx.save();
|
||
ctx.translate(ch.x, 0);
|
||
ctx.scale(ch.facing, 1);
|
||
ctx.translate(-ch.x, 0);
|
||
ctx.drawImage(img, fi * CHAR_FW, 0, CHAR_FW, CHAR_FH, drawX, drawY, CHAR_FW, CHAR_FH);
|
||
ctx.restore();
|
||
|
||
// 受伤闪白(叠加半透明白)
|
||
if (ch.hurtFlash > 0) {
|
||
ctx.save();
|
||
ctx.globalAlpha = Math.min(1, ch.hurtFlash * 2.5) * 0.55;
|
||
ctx.fillStyle = '#ffffff';
|
||
ctx.fillRect(drawX, drawY, CHAR_FW, CHAR_FH);
|
||
ctx.restore();
|
||
}
|
||
|
||
// HP 血条(头顶,常显加大;比例按种族最大生命)
|
||
const chHp = (typeof ch.hp === 'number' && ch.hp >= 0) ? ch.hp : (ch.hpMax || CHAR_HP_MAX);
|
||
const chHpMax = ch.hpMax || CHAR_HP_MAX;
|
||
{
|
||
const bw = 40, bh = 6;
|
||
const hx = ch.x - bw / 2, hy = drawY - 20;
|
||
ctx.save();
|
||
// 背景(黑色描边 + 深色底)
|
||
ctx.fillStyle = 'rgba(0,0,0,0.75)';
|
||
ctx.fillRect(hx - 1, hy - 1, bw + 2, bh + 2);
|
||
ctx.fillStyle = 'rgba(30,35,45,0.85)';
|
||
ctx.fillRect(hx, hy, bw, bh);
|
||
// 血量填充
|
||
const ratio = Math.max(0, Math.min(1, chHp / chHpMax));
|
||
const hpColor = ratio > 0.5 ? '#3ddc6a' : ratio > 0.25 ? '#ffc93c' : '#ff3b30';
|
||
ctx.fillStyle = hpColor;
|
||
ctx.fillRect(hx, hy, bw * ratio, bh);
|
||
// 高光(顶部细线)
|
||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||
ctx.fillRect(hx, hy, bw * ratio, 2);
|
||
ctx.restore();
|
||
}
|
||
|
||
// 护盾条(血条上方,青色;矮人铁壁触发后显示)
|
||
if (ch.shieldMax > 0 && ch.shield > 0) {
|
||
const bw = 40, bh = 4;
|
||
const hx = ch.x - bw / 2, hy = drawY - 27;
|
||
ctx.save();
|
||
ctx.fillStyle = 'rgba(0,0,0,0.65)';
|
||
ctx.fillRect(hx - 1, hy - 1, bw + 2, bh + 2);
|
||
ctx.fillStyle = 'rgba(16,44,64,0.9)';
|
||
ctx.fillRect(hx, hy, bw, bh);
|
||
const sRatio = Math.max(0, Math.min(1, ch.shield / ch.shieldMax));
|
||
ctx.fillStyle = '#37c7f0';
|
||
ctx.fillRect(hx, hy, bw * sRatio, bh);
|
||
ctx.fillStyle = 'rgba(255,255,255,0.35)';
|
||
ctx.fillRect(hx, hy, bw * sRatio, 2);
|
||
ctx.restore();
|
||
}
|
||
|
||
// 扣血飘字(-N 向上飘动渐隐;红色=扣血,青色=护盾吸收)
|
||
if (ch.dmgPopups && ch.dmgPopups.length) {
|
||
ctx.save();
|
||
ctx.font = 'bold 14px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
for (const p of ch.dmgPopups) {
|
||
const life = 1 - p.t / 0.9;
|
||
if (life <= 0) continue;
|
||
ctx.globalAlpha = Math.min(1, life);
|
||
ctx.fillStyle = p.shield ? '#37c7f0' : p.blocked ? '#9aa3b2' : '#ff5040';
|
||
ctx.fillText(p.blocked ? '0' : '-' + p.value, ch.x, ch.y - 46 - p.t * 26);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// 恢复飘字(+N 向上飘动渐隐;绿色=回血,青色=护盾获得)
|
||
if (ch.healPopups && ch.healPopups.length) {
|
||
ctx.save();
|
||
ctx.font = 'bold 12px sans-serif';
|
||
ctx.textAlign = 'center';
|
||
for (const p of ch.healPopups) {
|
||
const life = 1 - p.t / 0.8;
|
||
if (life <= 0) continue;
|
||
ctx.globalAlpha = Math.min(1, life);
|
||
ctx.fillStyle = p.shield ? '#37c7f0' : '#46e06a';
|
||
ctx.fillText('+' + p.value, ch.x, ch.y - 40 - p.t * 20);
|
||
}
|
||
ctx.restore();
|
||
}
|
||
|
||
// 名字标签
|
||
ctx.save();
|
||
const name = RACE_CONFIG[ch.race].name;
|
||
ctx.font = 'bold 11px sans-serif';
|
||
const tw = ctx.measureText(name).width;
|
||
const labelY = drawY - 8;
|
||
ctx.fillStyle = 'rgba(8,12,26,0.78)';
|
||
ctx.fillRect(ch.x - tw/2 - 4, labelY - 8, tw + 8, 16);
|
||
ctx.fillStyle = '#e0e0e0';
|
||
ctx.textAlign = 'center';
|
||
ctx.textBaseline = 'middle';
|
||
ctx.fillText(name, ch.x, labelY);
|
||
// 状态小点
|
||
ctx.fillStyle = ch.state === 'walk' ? '#4af' : ch.state === 'attack' ? '#f44' : ch.state === 'work' ? '#8bc34a' : ch.state === 'communicate' ? '#e7b85c' : '#8899aa';
|
||
ctx.beginPath();
|
||
ctx.arc(ch.x + tw/2 + 8, labelY, 3, 0, Math.PI * 2);
|
||
ctx.fill();
|
||
ctx.restore();
|
||
});
|
||
}
|
||
|
||
// ---- 点击检测(世界坐标)----
|
||
function hitChar(wx, wy) {
|
||
const vis = visibleMicroChars();
|
||
for (let i = vis.length - 1; i >= 0; i--) {
|
||
const ch = vis[i];
|
||
const dx = wx - ch.x, dy = wy - (ch.y - CHAR_FH * 0.5);
|
||
if (Math.hypot(dx, dy) < 28) return ch;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ---- 限制在浮岛椭圆内(已移除:用户要求生物可走到视图任意位置,不再卡边界) ----
|
||
function clampCharToIsland(ch) {
|
||
// no-op: boundary restriction removed; characters can roam freely within the micro canvas
|
||
}
|
||
|
||
// ---- UI 面板 ----
|
||
function updateCharPanel() {
|
||
const panel = document.getElementById('charPanel');
|
||
if (!panel) return;
|
||
const ch = selectedCharId ? getChar(selectedCharId) : null;
|
||
if (!ch) {
|
||
panel.innerHTML = '';
|
||
panel.style.display = 'none';
|
||
return;
|
||
}
|
||
panel.style.display = 'block';
|
||
const rc = RACE_CONFIG[ch.race] || RACE_CONFIG.human;
|
||
const stats = raceStats(ch.race);
|
||
const fmt = (v) => (v % 1 === 0 ? String(v) : String(parseFloat(v.toFixed(2))));
|
||
// 血量比例 + 颜色
|
||
const hpRatio = Math.max(0, Math.min(1, ch.hp / (ch.hpMax || CHAR_HP_MAX)));
|
||
const hpColor = hpRatio > 0.5 ? '#3ddc6a' : hpRatio > 0.25 ? '#ffc93c' : '#ff3b30';
|
||
// 当前攻击(含矮人狂暴加成)
|
||
const curAtk = currentAttackOf(ch);
|
||
const atkBonus = (ch.enrage && curAtk > ch.attack)
|
||
? ` <span style="color:#ffb454;">(+${fmt(curAtk - ch.attack)} 狂暴)</span>` : '';
|
||
// 护盾(矮人:显示当前值,未触发提示)
|
||
const shieldHtml = ch.shieldMax > 0
|
||
? `<div style="margin-top:5px;color:#8a9aaa;font-size:11px;">🛡 护盾 <b style="color:#37c7f0;">${ch.shield}</b>/${ch.shieldMax}<span style="color:#667;">${ch.shield > 0 ? '' : '(未触发)'}</span></div>`
|
||
: '';
|
||
const actions = ['idle', 'attack', 'work', 'communicate'];
|
||
const btns = actions.map(a => {
|
||
const active = ch.state === a ? 'background:#2a4a6a;border-color:#5a9acc;' : '';
|
||
return `<button class="charActBtn" data-act="${a}" style="${active}background:#1d2b4a;color:#cde;border:1px solid #3a5a8e;padding:5px 10px;border-radius:5px;cursor:pointer;font-size:12px;transition:background .15s;">${CHAR_CN[a]}</button>`;
|
||
}).join('');
|
||
panel.innerHTML = `
|
||
<div style="font-size:13px;font-weight:bold;color:#7dd3fc;margin-bottom:8px;">${rc.name} · ${CHAR_CN[ch.state]}</div>
|
||
<div style="font-size:11px;color:#8a9aaa;margin-bottom:3px;">❤ 生命 <b style="color:#e8eef7;">${Math.round(ch.hp)}</b>/${ch.hpMax}</div>
|
||
<div style="background:#0d1526;border-radius:3px;padding:1px;height:8px;">
|
||
<div style="width:${(hpRatio * 100).toFixed(1)}%;height:100%;background:${hpColor};border-radius:2px;transition:width .2s;"></div>
|
||
</div>
|
||
${shieldHtml}
|
||
<div style="margin-top:6px;color:#8a9aaa;font-size:11px;">👟 移速 <b style="color:#e8eef7;">${stats.speed}</b></div>
|
||
<div style="margin-top:5px;color:#8a9aaa;font-size:11px;">⚔ 攻击 <b style="color:#e8eef7;">${fmt(curAtk)}</b>${atkBonus}</div>
|
||
<div style="margin-top:5px;color:#8a9aaa;font-size:11px;">🛡 防御 <b style="color:#e8eef7;">${fmt(ch.defense)}</b></div>
|
||
<div style="margin-top:8px;padding:6px 8px;background:#16213a;border-radius:5px;color:#9fb2cc;font-size:11px;line-height:1.5;">📖 ${rc.trait || '暂无介绍'}</div>
|
||
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">${btns}</div>
|
||
<div style="margin-top:8px;">
|
||
<button id="charFreeMoveBtn" style="background:${ch.freeMove !== false ? '#1d4a2a' : '#3a3a4a'};color:${ch.freeMove !== false ? '#7ef0a0' : '#aab'};border:1px solid ${ch.freeMove !== false ? '#3a8a5e' : '#5a5a7a'};padding:5px 10px;border-radius:5px;cursor:pointer;font-size:12px;width:100%;">${ch.freeMove !== false ? '⏸ 停止自由移动' : '▶ 自由移动'}</button>
|
||
</div>
|
||
<div style="margin-top:8px;color:#8a9aaa;font-size:11px;">💡 点击地图移动 · WASD 方向键控制</div>
|
||
<div style="margin-top:6px;">
|
||
<button id="charDelBtn" style="background:#4a1e1e;color:#faa;border:1px solid #8a3a3a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;">删除</button>
|
||
</div>
|
||
`;
|
||
panel.querySelectorAll('.charActBtn').forEach(btn => {
|
||
btn.addEventListener('click', () => { triggerCharAction(ch.id, btn.dataset.act); updateCharPanel(); });
|
||
});
|
||
panel.querySelector('#charFreeMoveBtn').addEventListener('click', () => {
|
||
if (ch.freeMove !== false) {
|
||
// 停止自由移动:立即打断漫游行走 + 重置计时(freeMove=false 后漫游分支不再进入)
|
||
ch.freeMove = false;
|
||
ch.wanderT = 0;
|
||
if (!ch._userMove && ch.state === 'walk') {
|
||
ch.state = 'idle';
|
||
ch.frame = 0;
|
||
ch.frameT = 0;
|
||
}
|
||
} else {
|
||
// 开启自由移动:重置计时,取消选中后即可漫游
|
||
ch.freeMove = true;
|
||
ch.wanderT = 0;
|
||
ch.wanderInterval = 2 + Math.random() * 4;
|
||
}
|
||
updateCharPanel();
|
||
});
|
||
panel.querySelector('#charDelBtn').addEventListener('click', () => deleteChar(ch.id));
|
||
}
|
||
|
||
// 新放置角色默认是否自由移动(由选中角色面板上的自由移动按钮间接控制)
|
||
let charPlaceFreeMove = true;
|
||
|
||
// 导出到 window
|
||
window.createChar = createChar;
|
||
window.selectChar = selectChar;
|
||
window.deleteChar = deleteChar;
|
||
window.triggerCharAction = triggerCharAction;
|
||
window.setCharTarget = setCharTarget;
|
||
window.getChar = getChar;
|
||
window.updateChars = updateChars;
|
||
window.renderChars = renderChars;
|
||
window.hitChar = hitChar;
|
||
window.clampCharToIsland = clampCharToIsland;
|
||
window.updateCharPanel = updateCharPanel;
|
||
window.allSheetsLoaded = allSheetsLoaded;
|
||
window.loadCharSheet = loadCharSheet;
|
||
window.preloadAllSheets = preloadAllSheets;
|
||
window.microChars = microChars;
|
||
window.charSheets = charSheets;
|
||
window.charPlaceMode = charPlaceMode;
|
||
window.charPlaceRace = charPlaceRace;
|
||
window.CHAR_STATES = CHAR_STATES;
|
||
window.CHAR_CN = CHAR_CN;
|
||
window.RACE_CONFIG = RACE_CONFIG;
|
||
window.loadMicroChars = loadMicroChars;
|
||
window.saveMicroCharsNow = saveMicroCharsNow;
|
||
window.scheduleSaveChars = scheduleSaveChars;
|
||
window.visibleMicroChars = visibleMicroChars;
|
||
window.setCharAvoid = setCharAvoid;
|