// 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) ? ` (+${fmt(curAtk - ch.attack)} 狂暴)` : ''; // 护盾(矮人:显示当前值,未触发提示) const shieldHtml = ch.shieldMax > 0 ? `
🛡 护盾 ${ch.shield}/${ch.shieldMax}${ch.shield > 0 ? '' : '(未触发)'}
` : ''; const actions = ['idle', 'attack', 'work', 'communicate']; const btns = actions.map(a => { const active = ch.state === a ? 'background:#2a4a6a;border-color:#5a9acc;' : ''; return ``; }).join(''); panel.innerHTML = `
${rc.name} · ${CHAR_CN[ch.state]}
❤ 生命 ${Math.round(ch.hp)}/${ch.hpMax}
${shieldHtml}
👟 移速 ${stats.speed}
⚔ 攻击 ${fmt(curAtk)}${atkBonus}
📖 ${rc.trait || '暂无介绍'}
${btns}
💡 点击地图移动 · WASD 方向键控制
`; 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;