任务系统,新手引导

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
+1
View File
@@ -12,6 +12,7 @@
'data/codex_monster.xml',
'data/codex_race.xml',
'data/yt_world_build_item.xml',
'data/yt_world_task.xml',
'views/yt_world_views.xml',
# AI NPC(自 game_ai_npc 合并):先数据(建模型记录)→ 子视图(含 action) → 主视图 → 菜单(引用 action 与父菜单)
'data/game_npc_data.xml',
+151 -3
View File
@@ -340,10 +340,157 @@ class YtWorldApi(http.Controller):
char = request.env['yt.world.character'].sudo().search(
[('world_id', '=', world.id)], limit=1)
if not char:
char = request.env['yt.world.character'].sudo().create(
{'world_id': world.id})
# 认领当前登录人的玩家档案(hr.employee),把注册昵称同步为角色名
vals = {'world_id': world.id}
uid = request.session.uid
if uid:
emp = request.env['hr.employee'].sudo().search(
[('user_id', '=', uid)], limit=1)
if emp:
vals['employee_id'] = emp.id
vals['name'] = emp.name or char.name
char = request.env['yt.world.character'].sudo().create(vals)
else:
# 兼容老数据:已存在角色但没关联 employee 时补上
if not char.employee_id:
uid = request.session.uid
if uid:
emp = request.env['hr.employee'].sudo().search(
[('user_id', '=', uid)], limit=1)
if emp:
char.sudo().write({'employee_id': emp.id,
'name': emp.name or char.name})
return char
# ========== 任务系统序列化(get_tasks / task_progress 共用) ==========
def _serialize_tasks(self, char):
"""把任务定义 + 角色进度汇总成前端面板可直接渲染的结构。"""
done_codes = [t['code'] for t in (char.tasks or []) if t.get('state') == 'done']
tasks = request.env['yt.world.task'].sudo().search(
[('active', '=', True)], order='chapter, sequence, id')
out = []
for t in tasks:
prereq = t.prereq or []
entry = None
for x in (char.tasks or []):
if x.get('code') == t.code:
entry = x
break
if entry and entry.get('state') == 'done':
state = 'done'
elif any(p not in done_codes for p in prereq):
state = 'locked'
elif entry and entry.get('state') == 'claimable':
state = 'claimable'
else:
state = 'in_progress'
progress = (entry or {}).get('progress') or {}
objs = []
for o in (t.objectives or []):
oid = o.get('id') or ('%s:%s' % (o.get('type'), o.get('key')))
need = o.get('count', 1) or 1
have = min(progress.get(oid, 0), need)
objs.append({
'type': o.get('type'), 'key': o.get('key'),
'label': o.get('label'), 'hint': o.get('hint'),
'have': have, 'need': need,
})
out.append({
'code': t.code, 'chapter': t.chapter, 'title': t.title,
'desc': t.desc or '', 'objectives': objs,
'reward_exp': t.reward_exp or 0,
'reward_res': t.reward_res or {},
'prereq': prereq, 'state': state,
})
chapters = sorted({t['chapter'] for t in out})
return chapters, out
@http.route('/yt_world/api/tasks', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
def get_tasks(self, **kw):
"""返回任务定义 + 当前角色进度,前端据此渲染面板。"""
auth_err = self._require_user()
if auth_err:
return _json(auth_err)
world = self._get_world()
if not world:
return _json({'ok': False, 'error': 'no_world'})
char = self._get_char(world)
# 回溯校验:角色等级可能已满足 level_up 类型任务,补发事件
char.on_game_event('level_up', {'level': char.level})
chapters, out = self._serialize_tasks(char)
return _json({
'ok': True,
'chapters': chapters,
'tasks': out,
'char': {
'name': char.name, 'level': char.level, 'exp': char.exp,
'exp_max': char.exp_max, 'gold': char.gold,
},
})
@http.route('/yt_world/api/task/progress', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def task_progress(self, **kw):
"""接收前端游戏事件并推进任务进度。body: {"event":"build_placed","payload":{"code":"farmland"}}
返回更新后的任务列表(与 /tasks 同结构),claimable 列出本次新可领取的任务。"""
auth_err = self._require_user()
if auth_err:
return _json(auth_err)
world = self._get_world()
if not world:
return _json({'ok': False, 'error': 'no_world'})
body = _post_body()
event = (body.get('event') or '').strip()
payload = body.get('payload') or {}
if not event:
return _json({'ok': False, 'error': 'bad_params'})
char = self._get_char(world)
res = char.on_game_event(event, payload)
# 回溯校验:角色等级可能已满足 level_up 类型任务,补发事件
char.on_game_event('level_up', {'level': char.level})
chapters, out = self._serialize_tasks(char)
return _json({
'ok': True,
'chapters': chapters,
'tasks': out,
'char': {
'name': char.name, 'level': char.level, 'exp': char.exp,
'exp_max': char.exp_max, 'gold': char.gold,
},
'claimable': res.get('claimable', []),
})
@http.route('/yt_world/api/task/complete', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def complete_task(self, **kw):
"""领取/完成任务。body: {"code": "ch1_welcome"}
校验前置→发奖→写进度,返回最新角色状态。"""
auth_err = self._require_user()
if auth_err:
return _json(auth_err)
world = self._get_world()
if not world:
return _json({'ok': False, 'error': 'no_world'})
body = _post_body()
code = (body.get('code') or '').strip()
if not code:
return _json({'ok': False, 'error': 'bad_params'})
char = self._get_char(world)
res = char.complete_task(code)
if not res.get('ok'):
return _json(res)
return _json({
'ok': True,
'already': res.get('already', False),
'char': {
'name': char.name, 'level': char.level, 'exp': char.exp,
'exp_max': char.exp_max, 'gold': char.gold,
},
'reward_exp': res.get('reward_exp', 0),
'reward_res': res.get('reward_res', {}),
})
@http.route('/yt_world/api/char', type='http', auth='public',
methods=['GET', 'OPTIONS'], csrf=False)
def get_char(self, **kw):
@@ -358,7 +505,8 @@ class YtWorldApi(http.Controller):
return _json({
'ok': True,
'name': char.name, 'level': char.level, 'exp': char.exp,
'gold': char.gold, 'inventory': char.inventory or {},
'exp_max': char.exp_max or 100, 'gold': char.gold,
'inventory': char.inventory or {},
'equip': char.equip or {'weapon': None, 'armor': None, 'accessory': None},
})
+98
View File
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="0">
<!-- ===== 第1章 初入世界 ===== -->
<record id="task_ch1_welcome" model="yt.world.task">
<field name="chapter">1</field>
<field name="code">ch1_welcome</field>
<field name="title">欢迎来到宇森世界</field>
<field name="desc">你正式成为这个世界的缔造者。打开任务面板,即可领取新手启程礼包。</field>
<field name="objectives" eval="[{'id':'o1','type':'panel_opened','count':1,'label':'打开任务面板','hint':'点击顶部 📜 任务按钮'}]"/>
<field name="reward_exp">50</field>
<field name="reward_res" eval="{'gold': 100}"/>
<field name="prereq" eval="[]"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
<record id="task_ch1_name" model="yt.world.task">
<field name="chapter">1</field>
<field name="code">ch1_name</field>
<field name="title">为你的世界命名</field>
<field name="desc">每个世界都该有自己的名字。点击世界标题,为它取一个响亮的名号。</field>
<field name="objectives" eval="[{'id':'o1','type':'world_named','count':1,'label':'为世界命名','hint':'点击顶部世界标题后回车保存'}]"/>
<field name="reward_exp">30</field>
<field name="reward_res" eval="{'gold': 50}"/>
<field name="prereq" eval="['ch1_welcome']"/>
<field name="sequence">20</field>
<field name="active">True</field>
</record>
<!-- ===== 第2章 建造启蒙 ===== -->
<record id="task_ch2_build" model="yt.world.task">
<field name="chapter">2</field>
<field name="code">ch2_build</field>
<field name="title">奠基第一座建筑</field>
<field name="desc">进入任意地块,从建造面板放置你的第一座建筑,让世界开始运转。</field>
<field name="objectives" eval="[{'id':'o1','type':'build_placed','count':1,'label':'放置任意建筑','hint':'进入地块,选素材后左键落地'}]"/>
<field name="reward_exp">40</field>
<field name="reward_res" eval="{'gold': 80}"/>
<field name="prereq" eval="['ch1_name']"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
<record id="task_ch2_harvest" model="yt.world.task">
<field name="chapter">2</field>
<field name="code">ch2_harvest</field>
<field name="title">第一次收获</field>
<field name="desc">在农田上使用「收获」动作,体验建筑产出资源的乐趣。</field>
<field name="objectives" eval="[{'id':'o1','type':'building_action','key':'farmland_harvest','count':1,'label':'收获一次农田','hint':'选中农田 → 收获'}]"/>
<field name="reward_exp">40</field>
<field name="reward_res" eval="{'gold': 60}"/>
<field name="prereq" eval="['ch2_build']"/>
<field name="sequence">20</field>
<field name="active">True</field>
</record>
<!-- ===== 第3章 资源流转 ===== -->
<record id="task_ch3_food" model="yt.world.task">
<field name="chapter">3</field>
<field name="code">ch3_food</field>
<field name="title">粮仓初盈</field>
<field name="desc">反复经营你的农田与作坊,累计收获 5 份粮食,支撑世界运转。</field>
<field name="objectives" eval="[{'id':'o1','type':'resource_collected','key':'food','count':5,'label':'累计收获粮食 ×5','hint':'多次使用农田「收获」/风车「加工」'}]"/>
<field name="reward_exp">60</field>
<field name="reward_res" eval="{'gold': 100}"/>
<field name="prereq" eval="['ch2_harvest']"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
<!-- ===== 第4章 生灵入驻 ===== -->
<record id="task_ch4_char" model="yt.world.task">
<field name="chapter">4</field>
<field name="code">ch4_char</field>
<field name="title">首位居民</field>
<field name="desc">一个世界不能只有建筑。在地块中放置你的第一名种族角色,带来生机。</field>
<field name="objectives" eval="[{'id':'o1','type':'char_recruited','count':1,'label':'入驻一名种族角色','hint':'在地块中放置一个种族角色'}]"/>
<field name="reward_exp">80</field>
<field name="reward_res" eval="{'gold': 150}"/>
<field name="prereq" eval="['ch3_food']"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
<!-- ===== 第5章 文明初成 ===== -->
<record id="task_ch5_level" model="yt.world.task">
<field name="chapter">5</field>
<field name="code">ch5_level</field>
<field name="title">文明初成</field>
<field name="desc">完成前面的历练,让你的角色积累经验、晋升至 2 级,开启更广阔的世界。</field>
<field name="objectives" eval="[{'id':'o1','type':'level_up','key':2,'count':1,'label':'角色升至 2 级','hint':'完成更多任务积累经验'}]"/>
<field name="reward_exp">100</field>
<field name="reward_res" eval="{'gold': 200}"/>
<field name="prereq" eval="['ch4_char']"/>
<field name="sequence">10</field>
<field name="active">True</field>
</record>
</odoo>
+1
View File
@@ -4,6 +4,7 @@ from . import yt_world_tile_micro
from . import yt_world_micro_char
from . import yt_world_build_item
from . import yt_world_game
from . import yt_world_task
from . import yt_resource
from . import yt_codex
from . import npc
+138
View File
@@ -29,14 +29,22 @@ class YtWorldCharacter(models.Model):
world_id = fields.Many2one('yt.world', string='世界', required=True,
ondelete='cascade', index=True)
employee_id = fields.Many2one('hr.employee', string='所属玩家',
ondelete='cascade', index=True)
name = fields.Char(string='角色名', default='旅者')
level = fields.Integer(string='等级', default=1)
exp = fields.Integer(string='经验', default=0)
exp_max = fields.Integer(string='升级所需经验', default=100)
gold = fields.Integer(string='金币', default=100)
inventory = fields.Json(string='背包', default=dict)
inventory_display = fields.Text(string='背包(可读)', compute='_compute_displays')
equip = fields.Json(string='装备', default=dict)
equip_display = fields.Text(string='装备(可读)', compute='_compute_displays')
# 任务进度:[{code, progress:{obj_id:have}, state, ts}]
# state ∈ in_progress / claimable / done(locked 为前端按前置派生,不落库)
tasks = fields.Json(string='任务进度', default=list)
tutorial_chapter = fields.Integer(string='当前章节', default=1)
tutorial_step = fields.Integer(string='章节内步骤', default=0)
@api.depends('inventory', 'equip')
def _compute_displays(self):
@@ -44,6 +52,136 @@ class YtWorldCharacter(models.Model):
r.inventory_display = _fmt_kv(r.inventory)
r.equip_display = _fmt_kv(r.equip)
# ========== 任务系统 ==========
# 前端可发射的游戏事件类型(服务端事件总线):
# panel_opened / world_named / build_placed{code} /
# building_action{key:'code_action'} / resource_collected{res} /
# char_recruited{race} / creature_recruited{species} / level_up{level}
@staticmethod
def _obj_id(o):
return '%s:%s' % (o.get('type'), o.get('key'))
@staticmethod
def _match_event(o, event, payload):
"""单条 objective 是否被该事件命中。"""
if o.get('type') != event:
return False
key = o.get('key')
if key is None:
return True
if event == 'level_up':
try:
return int(payload.get('level', 0)) >= int(key)
except Exception:
return False
field = {
'build_placed': 'code',
'building_action': 'key',
'resource_collected': 'res',
'char_recruited': 'race',
'creature_recruited': 'species',
}.get(event)
if not field:
return True # 不带 key 的事件(panel_opened/world_named)命中即计数
val = payload.get(field)
if val is None:
return True
return str(val) == str(key)
def on_game_event(self, event, payload=None):
"""服务端事件总线:遍历未完成任务的 objectives,命中则推进进度,满额置 claimable。
返回 {ok, changed, claimable:[codes]} 供接口提示前端。"""
self.ensure_one()
payload = payload or {}
Task = self.env['yt.world.task'].sudo()
tasks = list(self.tasks or [])
done_codes = [t['code'] for t in tasks if t.get('state') == 'done']
newly_claimable = []
changed = False
for task in Task.search([('active', '=', True)], order='chapter, sequence, id'):
code = task.code
idx = next((i for i, t in enumerate(tasks) if t.get('code') == code), None)
entry = tasks[idx] if idx is not None else None
if entry and entry.get('state') in ('done', 'claimable'):
continue # 已结算/待领取,不再推进
prereq = task.prereq or []
if any(p not in done_codes for p in prereq):
continue # 前置未解锁
objectives = task.objectives or []
if not objectives:
continue
prog = dict((entry or {}).get('progress') or {})
was_claimable = bool(entry and entry.get('state') == 'claimable')
for o in objectives:
oid = o.get('id') or self._obj_id(o)
need = o.get('count', 1) or 1
have = prog.get(oid, 0)
if have >= need:
continue
if self._match_event(o, event, payload):
prog[oid] = have + 1
changed = True
all_done = all(
(prog.get(o.get('id') or self._obj_id(o), 0)) >= (o.get('count', 1) or 1)
for o in objectives)
new_state = 'claimable' if all_done else 'in_progress'
rec = {'code': code, 'progress': prog, 'state': new_state}
if idx is not None:
tasks[idx] = rec
else:
tasks.append(rec)
changed = True
if new_state == 'claimable' and not was_claimable:
newly_claimable.append(code)
if changed:
self.sudo().write({'tasks': tasks})
return {'ok': True, 'changed': changed, 'claimable': newly_claimable}
def complete_task(self, code, task_model=None):
"""领取任务奖励(仅 state==claimable 可领)。校验前置→发奖→置 done。"""
self.ensure_one()
Task = task_model or self.env['yt.world.task'].sudo()
task = Task.search([('code', '=', code), ('active', '=', True)], limit=1)
if not task:
return {'ok': False, 'error': 'task_not_found', 'code': code}
# 前置校验
prereq = task.prereq or []
done_codes = [t['code'] for t in (self.tasks or []) if t.get('state') == 'done']
for p in prereq:
if p not in done_codes:
return {'ok': False, 'error': 'prereq_unmet', 'code': p}
# 找到进度条目
tasks = list(self.tasks or [])
entry = next((t for t in tasks if t.get('code') == code), None)
if entry and entry.get('state') == 'done':
return {'ok': True, 'already': True, 'level': self.level,
'exp': self.exp, 'gold': self.gold}
if not entry or entry.get('state') != 'claimable':
return {'ok': False, 'error': 'not_claimable', 'code': code}
# 加经验(带越级)
exp = self.exp + (task.reward_exp or 0)
level = self.level
exp_max = self.exp_max
while exp >= exp_max:
exp -= exp_max
level += 1
exp_max = int(exp_max * 1.3) # 每级所需经验递增
# 加货币
gold = self.gold + int((task.reward_res or {}).get('gold', 0))
# 置已完成
for i, t in enumerate(tasks):
if t.get('code') == code:
tasks[i] = {'code': code, 'progress': t.get('progress') or {},
'state': 'done', 'ts': fields.Datetime.to_string(fields.Datetime.now())}
break
self.sudo().write({
'level': level, 'exp': exp, 'exp_max': exp_max,
'gold': gold, 'tasks': tasks,
})
return {'ok': True, 'level': level, 'exp': exp, 'exp_max': exp_max,
'gold': gold, 'reward_exp': task.reward_exp or 0,
'reward_res': task.reward_res or {}}
class YtWorldCreature(models.Model):
_name = 'yt.world.creature'
+32
View File
@@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
"""宇森世界 · 任务定义模型。
任务定义放后端(数据驱动),前端只渲染。与 yt.world.build.item 同一套路:
- 一条 yt.world.task 记录 = 一个任务(含章节/目标/奖励/前置)
- 玩家进度挂在 yt.world.character.tasks(Json 列表),不另建表
"""
from odoo import models, fields
class YtWorldTask(models.Model):
_name = 'yt.world.task'
_description = '世界任务(新手主线)'
_order = 'chapter, sequence, id'
chapter = fields.Integer(string='章节', default=1)
code = fields.Char(string='任务编码', required=True, index=True)
title = fields.Char(string='标题', required=True)
desc = fields.Text(string='描述')
# 目标步骤:[{key, label, hint}],前端渲染用;后端仅做展示
objectives = fields.Json(string='目标步骤', default=list)
reward_exp = fields.Integer(string='经验奖励', default=0)
# 货币奖励:{gold: 100} 等;gold 走 character.gold,其余走 inventory
reward_res = fields.Json(string='货币奖励', default=dict)
# 前置任务编码列表,全部完成后本任务才解锁
prereq = fields.Json(string='前置任务', default=list)
sequence = fields.Integer(string='排序', default=10)
active = fields.Boolean(string='启用', default=True)
_sql_constraints = [
('uniq_code', 'unique(code)', '任务编码必须唯一'),
]
@@ -33,3 +33,6 @@ access_game_npc_knowledge,game.npc.knowledge user,model_game_npc_knowledge,base.
access_yt_world_micro_char_user,yt.world.micro.char user,model_yt_world_micro_char,base.group_user,1,1,1,1
access_yt_world_micro_char_public,yt.world.micro.char public,model_yt_world_micro_char,base.group_public,1,0,0,0
access_yt_world_micro_char_portal,yt.world.micro.char portal,model_yt_world_micro_char,base.group_portal,1,0,0,0
access_yt_world_task_user,yt.world.task user,model_yt_world_task,base.group_user,1,1,1,1
access_yt_world_task_public,yt.world.task public,model_yt_world_task,base.group_public,1,0,0,0
access_yt_world_task_portal,yt.world.task portal,model_yt_world_task,base.group_portal,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
33 access_yt_world_micro_char_user yt.world.micro.char user model_yt_world_micro_char base.group_user 1 1 1 1
34 access_yt_world_micro_char_public yt.world.micro.char public model_yt_world_micro_char base.group_public 1 0 0 0
35 access_yt_world_micro_char_portal yt.world.micro.char portal model_yt_world_micro_char base.group_portal 1 0 0 0
36 access_yt_world_task_user yt.world.task user model_yt_world_task base.group_user 1 1 1 1
37 access_yt_world_task_public yt.world.task public model_yt_world_task base.group_public 1 0 0 0
38 access_yt_world_task_portal yt.world.task portal model_yt_world_task base.group_portal 1 0 0 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 144 KiB

After

Width:  |  Height:  |  Size: 75 KiB

+3
View File
@@ -280,6 +280,9 @@ function createChar(race, x, y) {
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); }
+39
View File
@@ -262,6 +262,45 @@ const API = {
async codexRaces() {
return (await fetch(_worldUrl('/yt_world/api/codex/races'), { credentials: _wcred })).json();
},
// ---------- 新手主线任务 ----------
async tasks() {
const r = await fetch(_worldUrl('/yt_world/api/tasks'), { credentials: _wcred });
if (!r.ok) return { ok: false, error: 'http_' + r.status };
return await r.json();
},
async taskComplete(code) {
try {
const r = await fetch(_worldUrl('/yt_world/api/task/complete'), {
method: 'POST', credentials: _wcred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) {
return { ok: false, error: 'auth' };
}
const j = await r.json();
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
} catch (e) {
return { ok: false, error: 'network', detail: String(e && e.message || e) };
}
},
// 发射游戏事件,推进任务进度(服务端事件总线)
async taskProgress(event, payload) {
try {
const r = await fetch(_worldUrl('/yt_world/api/task/progress'), {
method: 'POST', credentials: _wcred,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ event, payload: payload || {} }),
});
if (r.redirected || (r.url && r.url.indexOf('/web/login') >= 0)) {
return { ok: false, error: 'auth' };
}
const j = await r.json();
return j && j.ok === false ? j : (j || { ok: false, error: 'empty' });
} catch (e) {
return { ok: false, error: 'network', detail: String(e && e.message || e) };
}
},
// 暴露后端 base,供图片等静态资源 URL 拼接(本地跨域→8069;线上同域→'')
base: API_CONFIG.base,
};
+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>