diff --git a/addons/yt_world/__manifest__.py b/addons/yt_world/__manifest__.py index 75ac61f..14a1d3e 100644 --- a/addons/yt_world/__manifest__.py +++ b/addons/yt_world/__manifest__.py @@ -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', diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py index 9346bb8..0e9736a 100644 --- a/addons/yt_world/controllers/world_api.py +++ b/addons/yt_world/controllers/world_api.py @@ -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}, }) diff --git a/addons/yt_world/data/yt_world_task.xml b/addons/yt_world/data/yt_world_task.xml new file mode 100644 index 0000000..cb2b410 --- /dev/null +++ b/addons/yt_world/data/yt_world_task.xml @@ -0,0 +1,98 @@ + + + + + 1 + ch1_welcome + 欢迎来到宇森世界 + 你正式成为这个世界的缔造者。打开任务面板,即可领取新手启程礼包。 + + 50 + + + 10 + True + + + + 1 + ch1_name + 为你的世界命名 + 每个世界都该有自己的名字。点击世界标题,为它取一个响亮的名号。 + + 30 + + + 20 + True + + + + + 2 + ch2_build + 奠基第一座建筑 + 进入任意地块,从建造面板放置你的第一座建筑,让世界开始运转。 + + 40 + + + 10 + True + + + + 2 + ch2_harvest + 第一次收获 + 在农田上使用「收获」动作,体验建筑产出资源的乐趣。 + + 40 + + + 20 + True + + + + + 3 + ch3_food + 粮仓初盈 + 反复经营你的农田与作坊,累计收获 5 份粮食,支撑世界运转。 + + 60 + + + 10 + True + + + + + 4 + ch4_char + 首位居民 + 一个世界不能只有建筑。在地块中放置你的第一名种族角色,带来生机。 + + 80 + + + 10 + True + + + + + 5 + ch5_level + 文明初成 + 完成前面的历练,让你的角色积累经验、晋升至 2 级,开启更广阔的世界。 + + 100 + + + 10 + True + + diff --git a/addons/yt_world/models/__init__.py b/addons/yt_world/models/__init__.py index 3541aed..768baee 100644 --- a/addons/yt_world/models/__init__.py +++ b/addons/yt_world/models/__init__.py @@ -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 diff --git a/addons/yt_world/models/yt_world_game.py b/addons/yt_world/models/yt_world_game.py index 5c23455..873756a 100644 --- a/addons/yt_world/models/yt_world_game.py +++ b/addons/yt_world/models/yt_world_game.py @@ -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' diff --git a/addons/yt_world/models/yt_world_task.py b/addons/yt_world/models/yt_world_task.py new file mode 100644 index 0000000..965bed0 --- /dev/null +++ b/addons/yt_world/models/yt_world_task.py @@ -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)', '任务编码必须唯一'), + ] diff --git a/addons/yt_world/security/ir.model.access.csv b/addons/yt_world/security/ir.model.access.csv index 9dbce8f..d431109 100644 --- a/addons/yt_world/security/ir.model.access.csv +++ b/addons/yt_world/security/ir.model.access.csv @@ -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 diff --git a/assets/buildings/spring/cottage/cottage_l.png b/assets/buildings/spring/cottage/cottage_l.png index 8d1d944..044847e 100644 Binary files a/assets/buildings/spring/cottage/cottage_l.png and b/assets/buildings/spring/cottage/cottage_l.png differ diff --git a/assets/buildings/spring/cottage/cottage_r.png b/assets/buildings/spring/cottage/cottage_r.png index 3ae8e1e..daa572a 100644 Binary files a/assets/buildings/spring/cottage/cottage_r.png and b/assets/buildings/spring/cottage/cottage_r.png differ diff --git a/assets/buildings/spring/restaurant/restaurant_l.png b/assets/buildings/spring/restaurant/restaurant_l.png index ccd6a3d..1c0d126 100644 Binary files a/assets/buildings/spring/restaurant/restaurant_l.png and b/assets/buildings/spring/restaurant/restaurant_l.png differ diff --git a/assets/buildings/spring/restaurant/restaurant_r.png b/assets/buildings/spring/restaurant/restaurant_r.png index 97cb6f6..2818a53 100644 Binary files a/assets/buildings/spring/restaurant/restaurant_r.png and b/assets/buildings/spring/restaurant/restaurant_r.png differ diff --git a/assets/buildings/spring/school/school_l.png b/assets/buildings/spring/school/school_l.png index e5482b0..e57319b 100644 Binary files a/assets/buildings/spring/school/school_l.png and b/assets/buildings/spring/school/school_l.png differ diff --git a/assets/buildings/spring/school/school_r.png b/assets/buildings/spring/school/school_r.png index 836d033..4c5cff5 100644 Binary files a/assets/buildings/spring/school/school_r.png and b/assets/buildings/spring/school/school_r.png differ diff --git a/assets/buildings/spring/windmill/windmill_l.png b/assets/buildings/spring/windmill/windmill_l.png index ea19f2a..42967ff 100644 Binary files a/assets/buildings/spring/windmill/windmill_l.png and b/assets/buildings/spring/windmill/windmill_l.png differ diff --git a/assets/buildings/spring/windmill/windmill_r.png b/assets/buildings/spring/windmill/windmill_r.png index 3ffbfd7..617f8c5 100644 Binary files a/assets/buildings/spring/windmill/windmill_r.png and b/assets/buildings/spring/windmill/windmill_r.png differ diff --git a/assets/characters.js b/assets/characters.js index 86dfe7f..23f4978 100644 --- a/assets/characters.js +++ b/assets/characters.js @@ -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); } diff --git a/assets/game-api.js b/assets/game-api.js index 487f6ba..3aebf5d 100644 --- a/assets/game-api.js +++ b/assets/game-api.js @@ -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, }; diff --git a/world.html b/world.html index 84ccdc4..ef7f459 100644 --- a/world.html +++ b/world.html @@ -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 平原村落 微观世界视角 - +
@@ -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 = `🪙 ${microChar.gold != null ? microChar.gold : 0} · ⭐ 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 = ` +
+ 🪙 ${gold} + ⭐ Lv.${lv} + ${name ? `${name}` : ''} +
+
+
+
+
+ ${exp}/${expMax} (${pct}%) +
`; +} + +// ============ 任务系统(新手主线,后端驱动) ============ +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 '
任务加载中…
'; + } + 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 += `
第 ${ch} 章
`; + 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 += ` +
+
+ ${o.label || o.type}${o.hint ? ` (${o.hint})` : ''} + ${have}/${need} +
+
+
+
+
`; + }); + const btnLabel = done ? '已完成' : (claimable ? '领取奖励' : (locked ? '未解锁' : '进行中')); + const btnColor = done ? '#33414f' : (claimable ? '#3a7afe' : '#33414f'); + const btnCursor = (done || locked || !claimable) ? 'not-allowed' : 'pointer'; + html += ` +
+
+ ${t.title} + ${stateLabel[t.state] || t.state} +
+
${t.desc || ''}
+ ${objHtml} + ${rewards.length ? `
奖励:${rewards.join(' · ')}
` : ''} + ${done ? '' : ``} +
`; + }); + }); + return html; +} + +async function openTaskPanel() { + // 首次进入可能任务数据尚未拉取,先确保就绪;再发射"打开面板"事件(ch1_welcome 由此变可领取) + if (!taskData) await fetchTasks(); + await emitTaskEvent('panel_opened'); + const body = `
${renderTaskPanel()}
+
完成主线任务可获经验与金币,推动世界成长。
`; + 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('📜 任务', '
任务系统即将开放

完成每日/主线任务可获得经验、金币与稀有素材。
'); - }); + if (btnTask) btnTask.addEventListener('click', () => { openTaskPanel(); }); const btnMall = document.getElementById('btnMall'); if (btnMall) btnMall.addEventListener('click', () => { openAppModal('🛒 商城', '
商城系统即将开放

可购买皮肤、道具、加速器与会员服务。
'); @@ -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('👤 个人信息', '
加载中…
'); + + 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('👤 个人信息', `
-
-
${avatarLetter}
+
+
+
${avatarLetter}
+
+
${mlv}
${name}
UID: ${d.game_uid || d.uid || '--'} · 头像框:${frame}
+
等级Lv.${mlv}
+
+
+
+
+ EXP ${mexp}/${mexpMax} +
性别${gender}
${desc}
称号${d.title || '萌新'}