// 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 || '👤', category: rc.category || 'sentient', 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, }; }); // 荒漠蝎(流沙荒漠 boss):Odoo 模板缺失时前端兜底注入。 // ⚠️ 正式上线应改为在 Odoo yt.world.race 建 desert_scorpion 模板(前端不写死数据)。 if (!map.desert_scorpion) { map.desert_scorpion = { name: '荒漠蝎', icon: '🦂', category: 'boss', speed: 42, attack: 30, defense: 10, hpMax: 900, energyMax: 100, attackRange: 76, regen: false, enrage: false, shieldRatio: 0, trait: '流沙荒漠的霸主,甲壳坚硬的巨型蝎王', fw: 96, // 帧宽(默认 48,boss 用更宽帧横展体型) scale: 1.9, // 整体缩放:个头很大 walk: 'assets/characters/desert_scorpion/desert_scorpion_walk_1x6.png', idle_attack: 'assets/characters/desert_scorpion/desert_scorpion_idle_attack_1x6.png', work_comm: null, }; } 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 => { const cfg = RACE_CONFIG[race]; ['walk','idle_attack'].forEach(k => loadCharSheet(race, k)); if (cfg.category !== 'animal') loadCharSheet(race, 'work_comm'); // 动物无工作/交流动作 }); } // ---- 角色管理 ---- 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, opts) { 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, // 自由移动开关(默认开) boss: !!(opts && opts.boss), // 是否首领(荒漠蝎等 boss) }; microChars.push(ch); if (!(opts && opts.noSelect)) selectChar(id); scheduleSaveChars(100); // 任务:首位居民(ch4_char)。createChar 是种族角色唯一创建入口, // 直接发 char_recruited 推进任务(不经由 microState.units,避免与角色 AI 渲染叠加)。 if (!(opts && opts.skipTask) && typeof emitTaskEvent === 'function') emitTaskEvent('char_recruited', { race: race }); return ch; } // 首领挑战·流沙荒漠:自动生成荒漠蝎 boss(仅当当前没有时才生成,避免重复) function ensureBossSpawn() { if (microChars && microChars.some(c => c.race === 'desert_scorpion')) return; if (typeof microCanvas === 'undefined' || !microCanvas) return; const x = microCanvas.width / 2; const y = microCanvas.height * 0.52; createChar('desert_scorpion', x, y, { skipTask: true, noSelect: true, boss: true }); if (typeof updateCharPanel === 'function') updateCharPanel(); } 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; const raceCfg = RACE_CONFIG[ch.race]; // 非智慧生物(动物)/ boss 无工作/交流动作,禁止触发 if (raceCfg && (raceCfg.category === 'animal' || raceCfg.category === 'boss') && (action === 'work' || action === 'communicate')) 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 raceCfg = RACE_CONFIG[ch.race]; const isAnimal = raceCfg && (raceCfg.category === 'animal' || raceCfg.category === 'boss'); let goToChest = false; if (!isAnimal) { // 宝箱吸引:附近存在活跃宝箱时优先去开启(测试阶段吸引半径全屏) 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 = []; goToChest = true; } } // end if (!isAnimal) — 动物跳过整段宝箱吸引 // 漫游逻辑:动物始终走这里;智慧生物在无宝箱时走这里 if (!goToChest) { 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) { // 非循环动画(attack/work/communicate)播完 → 回 idle 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]; // 每种族帧宽:默认 CHAR_FW(48);荒漠蝎等 boss 用更宽帧(fw:96)横展体型 const rc = RACE_CONFIG[ch.race] || {}; const FW = rc.fw || CHAR_FW; const rscale = rc.scale || 1; // 死亡动画:两阶段——先倒地(前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 * FW, 0, FW, CHAR_FH, -FW / 2, -CHAR_FH * 0.9, 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 - 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(); // 角色精灵(以脚底 ch.x,ch.y 为锚点缩放,支持 boss 大体型 + 朝向翻转) ctx.save(); ctx.translate(ch.x, ch.y); ctx.scale(ch.facing * rscale, rscale); ctx.drawImage(img, fi * FW, 0, FW, CHAR_FH, -FW * 0.5, -CHAR_FH * 0.9, 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.translate(ch.x, ch.y); ctx.scale(ch.facing * rscale, rscale); ctx.fillRect(-FW * 0.5, -CHAR_FH * 0.9, 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) ? ` (+${fmt(curAtk - ch.attack)} 狂暴)` : ''; // 护盾(矮人:显示当前值,未触发提示) const shieldHtml = ch.shieldMax > 0 ? `
🛡 护盾 ${ch.shield}/${ch.shieldMax}${ch.shield > 0 ? '' : '(未触发)'}
` : ''; const cat = rc && rc.category; const actions = cat === 'boss' ? ['idle', 'attack', 'walk'] : (cat === 'animal') ? ['idle', 'attack'] : ['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}
🛡 防御 ${fmt(ch.defense)}
📖 ${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.ensureBossSpawn = ensureBossSpawn; 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;