Files
game/assets/characters.js
T
2026-09-03 15:31:15 +08:00

994 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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) {
let dmg = damage;
// 护盾优先吸收(矮人铁壁)
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 }); // 青色护盾吸收
}
}
// 剩余伤害扣血
if (dmg > 0) {
other.hp = Math.max(0, (typeof other.hp === 'number' ? other.hp : (other.hpMax || CHAR_HP_MAX)) - dmg);
other.hurtFlash = 0.4; // 受伤闪白
other.lastHurt = now; // 记录受伤时间(脱战恢复计时用)
if (!other.dmgPopups) other.dmgPopups = [];
other.dmgPopups.push({ value: fmt(dmg), t: 0 }); // 红色扣血
}
// 矮人铁壁:血量首次 ≤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;
}
// 键盘 WASD 控制(仅选中角色):8 方向实时移动,优先级高于点击移动/漫游
const kbdDir = ch.selected ? wasdDirection() : 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') {
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.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' : '#ff5040';
ctx.fillText('-' + 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) {
if (typeof microCanvas === 'undefined' || !microCanvas) return;
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 = ch.x - cx, dy = ch.y - cy;
const d = Math.hypot(dx / Rx, dy / Ry);
if (d > 0.92) {
const k = 0.92 / d;
ch.x = cx + dx * k;
ch.y = cy + dy * k;
}
}
// ---- 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: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;