任务系统,新手引导

This commit is contained in:
李鹏宇
2026-08-18 19:36:52 +08:00
parent c777db0657
commit efbf69580a
18 changed files with 802 additions and 29 deletions
+336 -26
View File
@@ -558,10 +558,25 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
#appModal.active { display: flex; }
#appModal .modalContent {
background: #1a2030; border: 1px solid rgba(100,200,255,0.3); border-radius: 12px;
padding: 24px; max-width: 360px; width: 90%; pointer-events: auto;
padding: 24px; max-width: 360px; width: 90%; max-height: 80vh; pointer-events: auto;
display: flex; flex-direction: column;
}
#appModal .modalTitle { font-size: 16px; font-weight: bold; color: #e0e0e0; margin-bottom: 16px; flex-shrink: 0; }
#appModal .modalBody {
color: #cfd5ea; font-size: 14px; line-height: 1.6; overflow-y: auto; padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: rgba(100,180,255,0.35) rgba(255,255,255,0.05);
}
#appModal .modalBody::-webkit-scrollbar { width: 6px; }
#appModal .modalBody::-webkit-scrollbar-track {
background: rgba(255,255,255,0.05); border-radius: 4px; margin: 4px 0;
}
#appModal .modalBody::-webkit-scrollbar-thumb {
background: rgba(100,180,255,0.35); border-radius: 4px;
}
#appModal .modalBody::-webkit-scrollbar-thumb:hover {
background: rgba(100,200,255,0.55);
}
#appModal .modalTitle { font-size: 16px; font-weight: bold; color: #e0e0e0; margin-bottom: 16px; }
#appModal .modalBody { color: #cfd5ea; font-size: 14px; line-height: 1.6; }
#appModal .infoRow { display: flex; justify-content: space-between; margin: 8px 0; font-size: 13px; }
#appModal .infoLabel { color: #8899aa; }
#appModal .infoValue { color: #e0e0e0; }
@@ -871,7 +886,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<button class="backBtn" id="backBtn">← 返回世界</button>
<span class="microTitle" id="microTitle">平原村落</span>
<span class="microSubtitle" id="microSubtitle">微观世界视角</span>
<div id="charHud" style="display:none;margin-left:auto;padding:6px 12px;background:rgba(0,0,0,.35);border:1px solid rgba(100,200,255,.3);border-radius:10px;font-size:13px;color:#e0e8f2;"></div>
<div id="charHud" style="display:none;margin-left:auto;min-width:200px;padding:6px 12px;background:rgba(0,0,0,.35);border:1px solid rgba(100,200,255,.3);border-radius:10px;font-size:13px;color:#e0e8f2;"></div>
</div>
<div id="microBody">
<div id="microTopBar">
@@ -1749,22 +1764,22 @@ const MICRO_BUILD_ITEMS = [
// 建筑 / 设施 目录(兜底:未登录或 Odoo 未提供时直接用此;登录后由 /yt_world/api/micro/catalog 覆盖)。
// flat=true 表示平面俯视,岛上压成菱形贴地;否则为 3/4 立绘,竖直站立。
const MICRO_BUILD_EXTRA = [
{ id: 'cottage', cat: 'build', icon: 'assets/buildings/spring/cottage/cottage_l.png?v=4', name: '农舍', desc: '村民起居的基本居所,提供人口上限与夜间休息。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 120, statYield: { population: 4 } },
{ id: 'windmill', cat: 'build', icon: 'assets/buildings/spring/windmill/windmill_l.png?v=4', name: '风车', desc: '利用风力磨粉与提水,提升粮食加工效率。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 110, statYield: { flour: 2 } },
{ id: 'cottage', cat: 'build', icon: 'assets/buildings/spring/cottage/cottage_l.png?v=6', name: '农舍', desc: '村民起居的基本居所,提供人口上限与夜间休息。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 120, statYield: { population: 4 } },
{ id: 'windmill', cat: 'build', icon: 'assets/buildings/spring/windmill/windmill_l.png?v=5', name: '风车', desc: '利用风力磨粉与提水,提升粮食加工效率。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 110, statYield: { flour: 2 } },
{ id: 'blacksmith', cat: 'build', icon: 'assets/buildings/spring/blacksmith/blacksmith_l.png?v=5',name: '铁匠铺', desc: '锻造工具与武器,产出金属制品。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 130, statYield: { metal: 2 } },
{ id: 'grocery', cat: 'build', icon: 'assets/buildings/spring/grocery/grocery_l.png?v=5', name: '杂货铺', desc: '交易日用物资的小型商铺,促进经济流通。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { coin: 2 } },
{ id: 'temple', cat: 'build', icon: 'assets/buildings/spring/temple/temple_l.png?v=4', name: '神庙', desc: '供奉信仰的场所,提升居民精神状态与领地声望。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 160, statYield: { prestige: 3 } },
{ id: 'clinic', cat: 'build', icon: 'assets/buildings/spring/clinic/clinic_l.png?v=5', name: '医务室', desc: '诊治伤病,恢复居民生命值。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 100, statYield: { heal: 2 } },
{ id: 'restaurant', cat: 'build', icon: 'assets/buildings/spring/restaurant/restaurant_l.png?v=4',name: '酒馆', desc: '烹制料理,为居民提供饱食与心情加成。', flat: false, w: 2, h: 3, statLevel: 1, statHp: 95, statYield: { satiety: 3 } },
{ id: 'pasture', cat: 'business', icon: 'assets/buildings/spring/pasture/pasture_l.png?v=8', name: '牧场', desc: '放养牲畜,稳定产出肉奶类资源。', flat: false, w: 4, h: 4, statLevel: 1, statHp: 105, statYield: { meat: 2, milk: 1 } },
{ id: 'restaurant', cat: 'build', icon: 'assets/buildings/spring/restaurant/restaurant_l.png?v=5',name: '酒馆', desc: '烹制料理,为居民提供饱食与心情加成。', flat: false, w: 2, h: 3, statLevel: 1, statHp: 95, statYield: { satiety: 3 } },
{ id: 'pasture', cat: 'business', icon: 'assets/buildings/spring/pasture/pasture_l.png?v=13', name: '牧场', desc: '放养牲畜,稳定产出肉奶类资源。', flat: false, w: 4, h: 4, statLevel: 1, statHp: 105, statYield: { meat: 2, milk: 1 } },
{ id: 'blackmarket', cat: 'build', icon: 'assets/buildings/spring/blackmarket/blackmarket_l.png?v=7',name:'商城', desc: '灰色交易点,可换取稀有物资但伴随风险。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 80, statYield: { rare: 1 } },
{ id: 'farmland', cat: 'business', icon: 'assets/buildings/spring/farmland/farmland.png', name: '农田', desc: '春季播种的耕地,定期产出粮食作物。', flat: true, w: 2, h: 2, statLevel: 1, statHp: 60, statYield: { food: 3 } },
{ id: 'well', cat: 'build', icon: 'assets/buildings/default/props/well_l.png', name: '水井', desc: '汲取地下水的圆井,保障饮水与灌溉。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 90, statYield: { water: 2 } },
{ id: 'school', cat: 'build', icon: 'assets/buildings/spring/school/school_l.png?v=4', name: '学堂', desc: '授业解惑之所,研读典籍提升领地科研与基础教学产出。', flat: false, w: 3, h: 2, statLevel: 1, statHp: 100, statYield: { research: 3, prestige: 1 } },
{ id: 'school', cat: 'build', icon: 'assets/buildings/spring/school/school_l.png?v=6', name: '学堂', desc: '授业解惑之所,研读典籍提升领地科研与基础教学产出。', flat: false, w: 3, h: 2, statLevel: 1, statHp: 100, statYield: { research: 3, prestige: 1 } },
{ id: 'library', cat: 'build', icon: 'assets/buildings/spring/library/library_l.png?v=5', name: '图书馆', desc: '收藏典籍与卷轴,提升领地科研与知识产出。', flat: false, w: 2, h: 3, statLevel: 1, statHp: 110, statYield: { research: 4 } },
{ id: 'barracks', cat: 'build', icon: 'assets/buildings/spring/barracks/barracks_l.png?v=6', name: '兵营', desc: '训练士兵与储备武备的军事建筑,提升领地军事产出。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 130, statYield: { military: 4 } },
{ id: 'guild', cat: 'build', icon: 'assets/buildings/spring/guild/guild_l.png?v=4', name: '公会', desc: '冒险者与住民集结之所,可发布委托与招募成员。', flat: false, w: 2, h: 2, statLevel: 1, statHp: 140, statYield: { prestige: 2, coin: 1 } },
{ id: 'village', cat: 'build', icon: 'assets/buildings/spring/village/village_l.png?v=4', name: '村庄', desc: '族群聚落的核心,提升人口上限与基础产出。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 150, statYield: { population: 6, food: 1 } },
{ id: 'village', cat: 'build', icon: 'assets/buildings/spring/village/village_l.png?v=5', name: '村庄', desc: '族群聚落的核心,提升人口上限与基础产出。', flat: false, w: 3, h: 3, statLevel: 1, statHp: 150, statYield: { population: 6, food: 1 } },
// 生物分类:9 种族角色(点击进入"点地面放置"模式,不进入建筑模式)
{ id: 'race_human', cat: 'bio', icon: 'assets/characters/human/human_thumb.png', name: '人族', isChar: true, race: 'human' },
{ id: 'race_demon', cat: 'bio', icon: 'assets/characters/demon/demon_thumb.png', name: '妖族', isChar: true, race: 'demon' },
@@ -1922,6 +1937,7 @@ async function microLoadCatalog() {
if (Array.isArray(it.actions) && it.actions.length) t.actions = it.actions; // 后端动作优先
});
microCatalogLoaded = true;
microLoadIcons(); // 目录覆盖 icon 后,用最终(干净 URL)的图标重新预加载,避免带 ?v= 缓存号与后端干净 URL 不一致导致建筑不显示
} catch (e) { /* 未登录/网络不可达:保留兜底常量 */ }
}
@@ -1954,7 +1970,11 @@ async function microLoadGame() {
// 持久化当前地块全部单位的角色化状态(等级/经验/装备/生命)到 Odoo
async function microSaveCreatures() {
if (!microState || !microState.units || !microState.units.length) return;
if (!microState || !microState.units) return;
// 注意:_knownUnitCids 在 setMicroTile 加载服务端居民时统一种子,
// 不要在这里用 microState.units 做延迟种子,否则刚放置的新角色 cid
// 会被提前标记为"已知",导致 char_recruited 事件漏发。
if (!microState.units.length) return;
const list = microState.units.map(u => ({
cid: u.id, name: u.name, species: u.role || 'none',
level: u.level, exp: u.exp, hp: u.hp, hp_max: u.hp_max || 120,
@@ -1965,7 +1985,15 @@ async function microSaveCreatures() {
}));
const res = await API.saveCreatures(list);
if (res && res.ok && Array.isArray(res.creatures)) {
res.creatures.forEach(x => { if (x.cid) microCreatureOverlay[x.cid] = x; });
const roleByCid = {};
list.forEach(u => { if (u.cid) roleByCid[u.cid] = u.role || u.species; });
res.creatures.forEach(x => {
if (x.cid) microCreatureOverlay[x.cid] = x;
if (x.cid && !_knownUnitCids.has(x.cid)) {
_knownUnitCids.add(x.cid);
emitTaskEvent('char_recruited', { race: roleByCid[x.cid] || 'human' });
}
});
}
}
@@ -1975,8 +2003,212 @@ function microRenderCharHud() {
if (!el) return;
if (!microChar) { el.style.display = 'none'; return; }
el.style.display = '';
el.innerHTML = `🪙 <b>${microChar.gold != null ? microChar.gold : 0}</b> · ⭐ Lv.${microChar.level || 1}`
+ (microChar.name ? ` · ${microChar.name}` : '');
const gold = microChar.gold != null ? microChar.gold : 0;
const lv = microChar.level || 1;
const exp = microChar.exp != null ? microChar.exp : 0;
const expMax = microChar.exp_max && microChar.exp_max > 0 ? microChar.exp_max : 100;
const pct = Math.max(0, Math.min(100, Math.round(exp / expMax * 100)));
const name = microChar.name ? microChar.name : '';
el.innerHTML = `
<div style="display:flex;align-items:center;gap:8px;white-space:nowrap;">
<span>🪙 <b>${gold}</b></span>
<span style="color:#ffd27f;">⭐ Lv.${lv}</span>
${name ? `<span style="color:#a9c7e0;">${name}</span>` : ''}
</div>
<div style="display:flex;align-items:center;gap:6px;margin-top:5px;">
<div style="position:relative;flex:1;height:7px;background:rgba(255,255,255,.12);border-radius:5px;overflow:hidden;">
<div style="position:absolute;left:0;top:0;bottom:0;width:${pct}%;background:linear-gradient(90deg,#3a7afe,#46e0c8);border-radius:5px;transition:width .35s ease;"></div>
</div>
<span style="font-size:11px;color:#9fb0c0;white-space:nowrap;">${exp}/${expMax} (${pct}%)</span>
</div>`;
}
// ============ 任务系统(新手主线,后端驱动) ============
let taskData = null; // {chapters, tasks, char}
let _knownUnitCids = new Set(); // 已记录的单位 cid,用于检测"新入驻居民"
// 建筑动作 → 产出的资源(用于发射 resource_collected 事件)
const TASK_RES = {
'farmland_harvest': 'food', 'farmland_sow': 'food', 'farmland_upgrade': 'food',
'windmill_collect': 'flour', 'windmill_produce': 'food',
'pasture_breed': 'meat', 'well_draw': 'water', 'blacksmith_forge': 'metal',
'grocery_restock': 'food', 'market_stall': 'gold',
};
// 发射游戏事件到服务端事件总线,推进任务进度;返回后刷新本地任务数据与(开着的)面板
async function emitTaskEvent(event, payload) {
try {
const r = await API.taskProgress(event, payload || {});
if (r && r.ok && r.tasks) {
taskData = { chapters: r.chapters, tasks: r.tasks, char: r.char };
if (microChar && r.char) {
microChar.level = r.char.level; microChar.exp = r.char.exp;
microChar.exp_max = r.char.exp_max; microChar.gold = r.char.gold;
microRenderCharHud();
}
const body = document.getElementById('taskPanelBody');
if (body) {
body.innerHTML = renderTaskPanel();
document.querySelectorAll('.task-claim-btn').forEach(b => {
b.onclick = () => claimTask(b.getAttribute('data-code'), b);
});
}
(r.claimable || []).forEach(code => {
const t = (r.tasks || []).find(x => x.code === code);
toast('🎯 新任务可领取:' + (t ? t.title : code));
});
}
} catch (e) { /* 离线兜底 */ }
}
async function fetchTasks() {
try {
const r = await API.tasks();
if (r && r.ok) {
taskData = r;
if (r.char) {
// 主世界登录时 microChar 可能尚未创建,这里兜底初始化,避免个人信息面板 fallback 到旧数据
if (!microChar) {
microChar = {
name: r.char.name, level: r.char.level, exp: r.char.exp,
exp_max: r.char.exp_max, gold: r.char.gold,
equip: { weapon: null, armor: null, accessory: null },
inventory: {}
};
} else {
microChar.name = r.char.name;
microChar.level = r.char.level;
microChar.exp = r.char.exp;
microChar.exp_max = r.char.exp_max;
microChar.gold = r.char.gold;
}
microRenderCharHud();
}
}
} catch (e) { /* 离线兜底 */ }
}
function renderTaskPanel() {
if (!taskData || !taskData.tasks) {
return '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">任务加载中…</div>';
}
const stateLabel = {
done: '✅ 已完成', claimable: '🎁 可领取',
in_progress: '🟢 进行中', locked: '🔒 未解锁',
};
let html = '';
taskData.chapters.forEach(ch => {
const list = taskData.tasks.filter(t => t.chapter === ch);
if (!list.length) return;
html += `<div style="margin:14px 0 6px;font-weight:600;color:#cdd9e5;">第 ${ch} 章</div>`;
list.forEach(t => {
const locked = t.state === 'locked';
const done = t.state === 'done';
const claimable = t.state === 'claimable';
const rewards = [];
if (t.reward_exp) rewards.push(`⭐${t.reward_exp} 经验`);
if (t.reward_res && t.reward_res.gold) rewards.push(`🪙${t.reward_res.gold} 金币`);
// 目标进度
let objHtml = '';
(t.objectives || []).forEach(o => {
const need = o.need || 1;
const have = Math.min(o.have || 0, need);
const pct = need ? Math.round(have / need * 100) : 100;
objHtml += `
<div style="margin:6px 0;">
<div style="display:flex;justify-content:space-between;font-size:12px;color:#9fb0c0;">
<span>${o.label || o.type}${o.hint ? ` <span style="color:#66788a;">(${o.hint})</span>` : ''}</span>
<span>${have}/${need}</span>
</div>
<div style="height:6px;background:#1d2733;border-radius:4px;overflow:hidden;margin-top:3px;">
<div style="height:100%;width:${pct}%;background:linear-gradient(90deg,#3a7afe,#38d0c8);border-radius:4px;transition:width .35s;"></div>
</div>
</div>`;
});
const btnLabel = done ? '已完成' : (claimable ? '领取奖励' : (locked ? '未解锁' : '进行中'));
const btnColor = done ? '#33414f' : (claimable ? '#3a7afe' : '#33414f');
const btnCursor = (done || locked || !claimable) ? 'not-allowed' : 'pointer';
html += `
<div style="border:1px solid #2a3645;border-radius:10px;padding:10px 12px;margin:8px 0;background:#16202c;${locked ? 'opacity:.5;' : ''}">
<div style="display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:600;color:#e8eef4;">${t.title}</span>
<span style="font-size:12px;color:#8899aa;">${stateLabel[t.state] || t.state}</span>
</div>
<div style="font-size:12px;color:#9fb0c0;margin:4px 0;">${t.desc || ''}</div>
${objHtml}
${rewards.length ? `<div style="font-size:12px;color:#ffd27f;margin:2px 0;">奖励:${rewards.join(' · ')}</div>` : ''}
${done ? '' : `<button class="task-claim-btn" data-code="${t.code}"
${claimable ? '' : 'disabled'}
style="margin-top:6px;padding:5px 14px;border:none;border-radius:8px;cursor:${btnCursor};
background:${btnColor};color:#fff;font-size:13px;">${btnLabel}</button>`}
</div>`;
});
});
return html;
}
async function openTaskPanel() {
// 首次进入可能任务数据尚未拉取,先确保就绪;再发射"打开面板"事件(ch1_welcome 由此变可领取)
if (!taskData) await fetchTasks();
await emitTaskEvent('panel_opened');
const body = `<div id="taskPanelBody">${renderTaskPanel()}</div>
<div style="text-align:center;color:#66788a;font-size:12px;margin-top:10px;">完成主线任务可获经验与金币,推动世界成长。</div>`;
openAppModal('📜 任务', body);
// 绑定领取按钮(setTimeout 等待 modal 注入 DOM)
setTimeout(() => {
document.querySelectorAll('.task-claim-btn').forEach(btn => {
btn.addEventListener('click', () => claimTask(btn.getAttribute('data-code'), btn));
});
}, 30);
}
async function claimTask(code, btn) {
try {
if (btn) { btn.disabled = true; btn.textContent = '领取中…'; }
const beforeLv = microChar ? microChar.level : 1;
const r = await API.taskComplete(code);
if (r && r.ok && r.char) {
// 回写角色状态到 HUD
if (microChar) {
microChar.name = r.char.name;
microChar.level = r.char.level;
microChar.exp = r.char.exp;
microChar.exp_max = r.char.exp_max;
microChar.gold = r.char.gold;
microRenderCharHud();
}
const gained = [];
if (r.reward_exp) gained.push(`⭐${r.reward_exp} 经验`);
if (r.reward_res && r.reward_res.gold) gained.push(`🪙${r.reward_res.gold} 金币`);
// 刷新面板
taskData = await API.tasks();
const body = document.getElementById('taskPanelBody');
if (body) body.innerHTML = renderTaskPanel();
// 重新绑定领取按钮
document.querySelectorAll('.task-claim-btn').forEach(b => {
b.addEventListener('click', () => claimTask(b.getAttribute('data-code'), b));
});
if (gained.length && !r.already) {
toast(`任务完成!获得 ${gained.join(' · ')}`);
}
// 升级事件 → 推进"升至 X 级"类任务
if (r.char.level > beforeLv) {
emitTaskEvent('level_up', { level: r.char.level });
}
} else if (r && r.error === 'prereq_unmet') {
if (btn) { btn.disabled = false; btn.textContent = '未解锁'; }
toast('请先完成前置任务');
} else if (r && r.error === 'not_claimable') {
if (btn) { btn.disabled = false; btn.textContent = '进行中'; }
toast('任务尚未完成');
} else {
// 未知错误:恢复按钮并提示,避免卡死
if (btn) { btn.disabled = false; btn.textContent = '领取'; }
toast('领取失败:' + (r && (r.error || r.detail) ? (r.error || r.detail) : '未知错误'));
}
} catch (e) {
if (btn) { btn.disabled = false; btn.textContent = '领取'; }
toast('领取异常:' + (e && e.message ? e.message : String(e)));
}
}
const MICRO_ICON_IMGS = {}; // 贴图缓存:path -> Image
function microLoadIcons() {
@@ -1988,7 +2220,7 @@ function microLoadIcons() {
else if (it.icon.includes('_r.png')) srcs.push(it.icon.replace('_r.png', '_l.png'));
}
srcs.forEach(src => {
if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)$/i.test(src) && !MICRO_ICON_IMGS[src]) {
if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(src) && !MICRO_ICON_IMGS[src]) {
const img = new Image();
img.src = src;
MICRO_ICON_IMGS[src] = img;
@@ -1998,7 +2230,7 @@ function microLoadIcons() {
// 自动道路/栅栏的「子贴图」(straight/curve/t/cross)不出现在建造面板,需主动预加载,避免首次绘制时闪烁/占位
[ROAD_AUTO_ICON].forEach(map => {
Object.values(map).forEach(src => {
if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)$/i.test(src) && !MICRO_ICON_IMGS[src]) {
if (typeof src === 'string' && /\.(png|webp|jpg|jpeg)(\?.*)?$/i.test(src) && !MICRO_ICON_IMGS[src]) {
const img = new Image();
img.src = src;
MICRO_ICON_IMGS[src] = img;
@@ -3062,6 +3294,7 @@ function enterMicroWorld(tile) {
microLoadIcons(); // 进入微观视图即预加载建造贴图,避免已放置建筑先显示文字
microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效
microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态
fetchTasks(); // 拉取任务定义 + 角色进度(新手主线)
// 初始化角色动画系统
if (typeof preloadAllSheets === 'function') preloadAllSheets();
if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel();
@@ -3386,6 +3619,8 @@ function startMicroRender() {
const onRoad = roadSet.size > 0;
if (!hasBgImage) {
microState.units.forEach((u, k) => {
// 由建筑层负责渲染的种族角色不再在这里画 emoji,避免重影
if (u._buildingRender) return;
// 初始化/重置巡逻状态(无路时清空,重铺路后自动接管)
if (onRoad) { if (!u.patrol || u.patrol.roadSize !== roadSet.size) { u.patrol = microInitPatrol(u, roadSet); u.patrol.roadSize = roadSet.size; } }
else if (u.patrol) u.patrol = null;
@@ -3760,7 +3995,15 @@ function onMicroUp(e) {
// 轻点:命中已放置建筑 → 弹出说明/交互弹窗(非建造模式)
const p = getMicroPoint(e);
const hit = microBuildingAt(microCellAt(p));
if (hit) { hideMicroHoverTip(); showBuildModal(hit); }
if (hit) {
hideMicroHoverTip();
const hitIt = microBuildItemById(hit.type);
if (hitIt && hitIt.isChar && hit._charCid) {
showUnitModal(hit._charCid); // 种族角色打开单位面板
} else {
showBuildModal(hit);
}
}
return;
}
// 超阈值:尝试切相邻地块;若边缘即墙(无相邻地块)navigateMicro 返回 false,
@@ -3800,6 +4043,9 @@ function setMicroTile(tile) {
microState.tile = tile;
applyMicroAccent(tile.terrain);
microState.units = tile.units || [];
// 把服务端已存在的居民标记为"已知",避免后续保存时把旧居民误判为新入驻
_knownUnitCids.clear();
(microState.units || []).forEach(u => { if (u.id) _knownUnitCids.add(u.id); });
microState.buildings = tile.hasBuilding
? [{ ...BUILDINGS[tile.buildingType], type: tile.buildingType, level: tile.buildingLevel, hp: 80 + tile.buildingLevel * 10 }]
: [];
@@ -4420,6 +4666,11 @@ async function microBuildingAct(type, action, label) {
if (res.gold != null) { microChar = microChar || {}; microChar.gold = res.gold; }
if (res.inventory && microChar) microChar.inventory = res.inventory;
microRenderCharHud();
// 发射任务事件:建筑交互 + 资源产出
const _bkey = type + '_' + action;
emitTaskEvent('building_action', { key: _bkey });
const _res = TASK_RES[_bkey];
if (_res) emitTaskEvent('resource_collected', { res: _res });
// 升级:用后端落库后的权威等级/经验回写并持久化
if (action === 'upgrade' && microOpenBuilding && res.level != null) {
microOpenBuilding.level = res.level;
@@ -5513,7 +5764,25 @@ function microCommitPlace() {
const g = microComputeGhost();
if (!g || !g.valid) { microToast('这里不能建造(红色区域)'); return; }
const it = microBuildItemById(microBuildSel);
if (it && it.isChar) {
// 种族角色:作为居民入驻(触发 char_recruited),同时保留建筑记录用于 PNG 渲染
const race = it.race || it.id;
const cid = race + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 5);
const raceEmoji = { human:'🧑', demon:'👹', dwarf:'🧔', elf:'🧝', dragon:'🐲', undead:'💀', orc:'👺', angel:'👼', werewolf:'🐺', golem:'🗿' };
microState.units.push({
id: cid, name: it.name, role: race, species: race, icon: raceEmoji[race] || '🧑',
level: 1, exp: 0, hp: 80, hp_max: 120,
energy: 80, mood: '😊', job: 'none',
equip: { weapon: null, armor: null, accessory: null },
status: 'resting', _buildingRender: true
});
microBuildList().push({ i: g.i, j: g.j, type: microBuildSel, rot: microBuildRot, w: it.w || 1, h: it.h || 1, level: 1, exp: 0, hp: (it.statHp || 100), _charCid: cid });
microSaveCreatures();
scheduleBuildSave();
return;
}
microBuildList().push({ i: g.i, j: g.j, type: microBuildSel, rot: microBuildRot, w: it.w || 1, h: it.h || 1, level: 1, exp: 0, hp: (it.statHp || 100) });
emitTaskEvent('build_placed', { code: microBuildSel });
scheduleBuildSave();
}
// 右键:命中建筑→删除;否则取消当前选择/编辑
@@ -5527,7 +5796,15 @@ function microBuildRightClick(p) {
if (hit) {
const list = microBuildList();
const idx = list.indexOf(hit);
if (idx >= 0) { list.splice(idx, 1); if (microEditRef === hit) microEditRef = null; scheduleBuildSave(); }
if (idx >= 0) {
// 删除种族角色时同步移除对应居民记录
if (hit._charCid && microState.units) {
const ui = microState.units.findIndex(u => u.id === hit._charCid);
if (ui >= 0) microState.units.splice(ui, 1);
microSaveCreatures();
}
list.splice(idx, 1); if (microEditRef === hit) microEditRef = null; scheduleBuildSave();
}
return;
}
if (microEditRef) { microEditRef = null; microToast('退出编辑'); }
@@ -6101,7 +6378,7 @@ function setupWorldNameEdit() {
localStorage.setItem('world_name', next);
try {
const r = await API.renameWorld(next);
if (r && r.ok && r.name) { restore(r.name); localStorage.setItem('world_name', r.name); }
if (r && r.ok && r.name) { restore(r.name); localStorage.setItem('world_name', r.name); emitTaskEvent('world_named'); }
else console.warn('rename 后端未响应,已本地保存', r);
} catch (e) {
console.warn('rename 后端失败,已本地保存', e);
@@ -6421,6 +6698,7 @@ if (typeof fitWorld === 'function') fitWorld();
const local = localStorage.getItem('world_name');
if (wnEl) wnEl.textContent = local || '我的世界';
}
fetchTasks(); // 登录即拉取新手主线任务(主世界初次进入也要触发,不只进地块时)
const backdrop = document.getElementById('uaBackdrop');
const closeBtn = document.getElementById('uaClose');
const setMenuOpen = (open) => {
@@ -6508,9 +6786,7 @@ if (typeof fitWorld === 'function') fitWorld();
const btnWebsite = document.getElementById('btnWebsite');
if (btnWebsite) btnWebsite.addEventListener('click', () => { location.href = 'index.html'; });
const btnTask = document.getElementById('btnTask');
if (btnTask) btnTask.addEventListener('click', () => {
openAppModal('📜 任务', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">任务系统即将开放<br><br>完成每日/主线任务可获得经验、金币与稀有素材。</div>');
});
if (btnTask) btnTask.addEventListener('click', () => { openTaskPanel(); });
const btnMall = document.getElementById('btnMall');
if (btnMall) btnMall.addEventListener('click', () => {
openAppModal('🛒 商城', '<div style="text-align:center;padding:24px;color:#8899aa;font-size:13px;">商城系统即将开放<br><br>可购买皮肤、道具、加速器与会员服务。</div>');
@@ -6549,24 +6825,58 @@ if (typeof fitWorld === 'function') fitWorld();
if (e.target === appModal) closeAppModal();
});
// 个人信息(从 cachedMe 动态渲染,点顶部用户区打开)
function openProfile() {
// 个人信息(从 cachedMe + 后端实时角色数据渲染)
async function openProfile() {
const d = cachedMe || {};
const name = d.name || d.login || '访客';
const avatarLetter = (name || 'M').trim().charAt(0).toUpperCase();
const gender = d.gender || '未设置';
const desc = d.desc || d.bio || '这位缔造者行事低调,尚未在宇森留下任何足迹。';
const frame = d.avatar_frame || d.frame || '初芒';
// 先弹出面板并显示加载中,避免点击后无反馈
openAppModal('👤 个人信息', '<div style="text-align:center;padding:24px;color:#9fb0c0;">加载中…</div>');
try {
// 强制从后端拉取最新角色数据,防止刷新后 microChar 为空导致经验清零
const fresh = await API.char();
if (fresh && fresh.ok) {
microChar = {
name: fresh.name, level: fresh.level, exp: fresh.exp,
exp_max: fresh.exp_max || 100, gold: fresh.gold || 0,
equip: fresh.equip || { weapon: null, armor: null, accessory: null },
inventory: fresh.inventory || {}
};
}
} catch (e) {
console.warn('加载角色信息失败,使用缓存数据', e);
}
const mv = (typeof microChar !== 'undefined' && microChar) ? microChar : null;
const mlv = mv ? (mv.level || 1) : (d.game_level || 1);
const mexp = mv ? (mv.exp || 0) : (d.game_exp || 0);
const mexpMax = mv ? (mv.exp_max || 100) : (d.game_exp_max || 100);
const mpct = mexpMax > 0 ? Math.min(100, Math.round(mexp / mexpMax * 100)) : 0;
openAppModal('👤 个人信息', `
<div style="display:flex;align-items:center;gap:14px;margin-bottom:12px;">
<div style="width:58px;height:58px;border-radius:50%;padding:3px;background:linear-gradient(135deg,#c77dff,#4af);box-shadow:0 0 14px rgba(199,125,255,.45);">
<div style="width:100%;height:100%;border-radius:50%;background:linear-gradient(135deg,#2a2348,#161b2e);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;">${avatarLetter}</div>
<div style="position:relative;flex:0 0 auto;">
<div style="width:58px;height:58px;border-radius:50%;padding:3px;background:linear-gradient(135deg,#c77dff,#4af);box-shadow:0 0 14px rgba(199,125,255,.45);">
<div style="width:100%;height:100%;border-radius:50%;background:linear-gradient(135deg,#2a2348,#161b2e);display:flex;align-items:center;justify-content:center;font-size:22px;font-weight:700;color:#fff;">${avatarLetter}</div>
</div>
<div style="position:absolute;right:-2px;bottom:-2px;min-width:22px;height:22px;padding:0 5px;border-radius:11px;background:linear-gradient(135deg,#3a7afe,#46e0c8);border:2px solid #16202c;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff;box-shadow:0 0 8px rgba(58,122,254,.5);">${mlv}</div>
</div>
<div style="min-width:0;">
<div style="font-size:16px;font-weight:700;color:#e0e0e0;">${name}</div>
<div style="font-size:12px;color:#8899aa;">UID: ${d.game_uid || d.uid || '--'} · 头像框:${frame}</div>
</div>
</div>
<div class="infoRow"><span class="infoLabel">等级</span><span class="infoValue">Lv.${mlv}</span></div>
<div style="display:flex;align-items:center;gap:6px;margin:4px 0 10px;">
<div style="position:relative;flex:1;height:7px;background:rgba(255,255,255,.12);border-radius:5px;overflow:hidden;">
<div style="position:absolute;left:0;top:0;bottom:0;width:${mpct}%;background:linear-gradient(90deg,#3a7afe,#46e0c8);border-radius:5px;transition:width .35s ease;"></div>
</div>
<span style="font-size:11px;color:#9fb0c0;white-space:nowrap;">EXP ${mexp}/${mexpMax}</span>
</div>
<div class="infoRow"><span class="infoLabel">性别</span><span class="infoValue">${gender}</span></div>
<div style="font-size:12px;color:#aab4c8;line-height:1.6;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:10px 12px;margin:10px 0;">${desc}</div>
<div class="infoRow"><span class="infoLabel">称号</span><span class="infoValue">${d.title || '萌新'}</span></div>