diff --git a/addons/yt_world/__manifest__.py b/addons/yt_world/__manifest__.py index 2cdf4af..75ac61f 100644 --- a/addons/yt_world/__manifest__.py +++ b/addons/yt_world/__manifest__.py @@ -1,6 +1,6 @@ { 'name': '宇森世界 - 沙盘与微观世界', - 'version': '0.2', + 'version': '0.3', 'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)', 'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。', 'category': 'Game', diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py index 625d5e1..9346bb8 100644 --- a/addons/yt_world/controllers/world_api.py +++ b/addons/yt_world/controllers/world_api.py @@ -40,19 +40,25 @@ class YtWorldApi(http.Controller): 前端统一带 ?db=game 且 credentials:'same-origin',故路由须为 type='http' 并以 _json 返回原始 JSON(与 game-api.js 的 fetch().json() 对齐)。""" - @http.route('/yt_world/api/world', type='http', auth='user', + @http.route('/yt_world/api/world', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False) def world(self, **kw): """取或建当前用户世界,导出 seed + 地块覆盖。""" - user = request.env.user + auth_err = self._require_user() + if auth_err: + return _json(auth_err) + user = self._get_user() rec = request.env['yt.world'].sudo().get_or_create_for_user(user) return _json(rec.export_state()) - @http.route('/yt_world/api/tiles/save', type='http', auth='user', + @http.route('/yt_world/api/tiles/save', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def save_tiles(self, **kw): """增量覆盖保存地块。body: {"tiles": [...]}""" - user = request.env.user + auth_err = self._require_user() + if auth_err: + return _json(auth_err) + user = self._get_user() world = request.env['yt.world'].sudo().search( [('user_id', '=', user.id)], limit=1) if not world: @@ -62,11 +68,14 @@ class YtWorldApi(http.Controller): world.import_tiles(tiles) return _json({'ok': True, 'saved': len(tiles)}) - @http.route('/yt_world/api/settings', type='http', auth='user', + @http.route('/yt_world/api/settings', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def save_settings(self, **kw): """保存相机/UI 偏好。body: {"settings": {...}}""" - user = request.env.user + auth_err = self._require_user() + if auth_err: + return _json(auth_err) + user = self._get_user() world = request.env['yt.world'].sudo().search( [('user_id', '=', user.id)], limit=1) body = _post_body() @@ -75,12 +84,15 @@ class YtWorldApi(http.Controller): world.settings = settings return _json({'ok': True}) - @http.route('/yt_world/api/world/rename', type='http', auth='user', + @http.route('/yt_world/api/world/rename', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def rename_world(self, **kw): """修改当前用户世界名称。body: {"name": "..."} 校验:trim 后 1-24 字符,禁控制字符,空白正常允许。""" - user = request.env.user + auth_err = self._require_user() + if auth_err: + return _json(auth_err) + user = self._get_user() world = request.env['yt.world'].sudo().search( [('user_id', '=', user.id)], limit=1) if not world: @@ -98,12 +110,15 @@ class YtWorldApi(http.Controller): _logger.info('yt.world rename uid=%s %r -> %r', user.id, old, name) return _json({'ok': True, 'name': name}) - @http.route('/yt_world/api/micro/build/save', type='http', auth='user', + @http.route('/yt_world/api/micro/build/save', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def save_micro_build(self, **kw): """保存某地块微观世界的建造布置。body: {"q":int,"r":int,"items":[{i,j,type}]} 找不到地块时自动建(保证种子地块也能落库记忆)。""" - user = request.env.user + auth_err = self._require_user() + if auth_err: + return _json(auth_err) + user = self._get_user() world = request.env['yt.world'].sudo().search( [('user_id', '=', user.id)], limit=1) if not world: @@ -124,12 +139,15 @@ class YtWorldApi(http.Controller): tile.micro_world_id.build_items = items return _json({'ok': True, 'count': len(items)}) - @http.route('/yt_world/api/micro/name', type='http', auth='user', + @http.route('/yt_world/api/micro/name', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def rename_micro(self, **kw): """修改某地块微观世界的名称。body: {"q":int,"r":int,"name":"..."} 找不到地块时自动建(保证种子地块也能落库记忆);与 rename_world 同样的校验。""" - user = request.env.user + auth_err = self._require_user() + if auth_err: + return _json(auth_err) + user = self._get_user() world = request.env['yt.world'].sudo().search( [('user_id', '=', user.id)], limit=1) if not world: @@ -157,12 +175,15 @@ class YtWorldApi(http.Controller): user.id, q, r, old, name) return _json({'ok': True, 'name': name, 'q': q, 'r': r}) - @http.route('/yt_world/api/micro/catalog', type='http', auth='user', + @http.route('/yt_world/api/micro/catalog', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False) def micro_catalog(self, **kw): """微观世界建造素材目录(前端 MICRO_BUILD_ITEMS 的单一来源)。 关联当前用户世界:返回「全局模板(world_id空) + 本世界专属」的素材。""" - user = request.env.user + auth_err = self._require_user() + if auth_err: + return _json(auth_err) + user = self._get_user() world = request.env['yt.world'].sudo().search( [('user_id', '=', user.id)], limit=1) if world: @@ -179,7 +200,7 @@ class YtWorldApi(http.Controller): out = [] for r in items.read(['code', 'name', 'category', 'icon', 'footprint_w', 'footprint_h', 'terrains', 'desc', 'flat', - 'stat_level', 'stat_hp', 'stat_yield']): + 'stat_level', 'stat_hp', 'stat_yield', 'actions']): if r['category'] not in seen: seen.append(r['category']) out.append({ @@ -191,6 +212,7 @@ class YtWorldApi(http.Controller): 'statLevel': r['stat_level'] or 1, 'statHp': r['stat_hp'] or 100, 'statYield': r['stat_yield'] or {}, + 'actions': r['actions'] or [], }) cats = [{'id': c, 'name': CAT_LABELS.get(c, c)} for c in seen] return _json({'cats': cats, 'items': out}) @@ -289,10 +311,30 @@ class YtWorldApi(http.Controller): ('market', 'upgrade'): {'cost': {'gold': 25}, 'msg': '集市升级申请已提交'}, } + def _require_user(self): + """auth='public' 时手动校验登录态;未登录返回统一错误字典。 + Odoo auth='public' 不会把 session uid 同步到 request.env.user, + 必须显式从 request.session.uid 读取真实用户。 + 仅校验「已登录 + 账号有效」:门户/游客账号(share=True)也允许访问 + 自己的世界——数据安全靠 world_id 按用户隔离,不依赖内部用户组。""" + uid = request.session.uid + if not uid: + return {'ok': False, 'error': 'auth'} + user = request.env['res.users'].sudo().browse(uid) + if not user.exists() or not user.active: + return {'ok': False, 'error': 'auth'} + return None + + def _get_user(self): + uid = request.session.uid + return request.env['res.users'].sudo().browse(uid) if uid else request.env['res.users'] + def _get_world(self): - user = request.env.user - return request.env['yt.world'].sudo().search( - [('user_id', '=', user.id)], limit=1) + """取当前用户世界,不存在则自动创建(保证任何入口/新账号都能保存)。""" + user = self._get_user() + if not user.exists(): + return None + return request.env['yt.world'].sudo().get_or_create_for_user(user) def _get_char(self, world): char = request.env['yt.world.character'].sudo().search( @@ -302,10 +344,13 @@ class YtWorldApi(http.Controller): {'world_id': world.id}) return char - @http.route('/yt_world/api/char', type='http', auth='user', + @http.route('/yt_world/api/char', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False) def get_char(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'}) @@ -317,10 +362,13 @@ class YtWorldApi(http.Controller): 'equip': char.equip or {'weapon': None, 'armor': None, 'accessory': None}, }) - @http.route('/yt_world/api/char/save', type='http', auth='user', + @http.route('/yt_world/api/char/save', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def save_char(self, **kw): """保存角色(等级/经验/金币/背包/装备)。body: 部分字段皆可。""" + 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'}) @@ -341,10 +389,13 @@ class YtWorldApi(http.Controller): 'equip': char.equip or {}, }}) - @http.route('/yt_world/api/creatures', type='http', auth='user', + @http.route('/yt_world/api/creatures', type='http', auth='public', methods=['GET', 'OPTIONS'], csrf=False) def get_creatures(self, **kw): """取当前世界全部生物。返回含服务器 id 与客户端 cid(partner.cid)。""" + 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'}) @@ -363,11 +414,14 @@ class YtWorldApi(http.Controller): }) return _json({'ok': True, 'creatures': out}) - @http.route('/yt_world/api/creatures/save', type='http', auth='user', + @http.route('/yt_world/api/creatures/save', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def save_creatures(self, **kw): """upsert 生物列表。body: {"creatures":[{cid,name,species,level,exp,hp,hp_max,energy,mood,job,equip,tile_ref,state}]} 按 partner.cid 匹配;存在则更新,否则新建。返回带 oid 的列表。""" + 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'}) @@ -405,11 +459,39 @@ class YtWorldApi(http.Controller): 'exp': rec.exp, 'hp': rec.hp, 'equip': rec.equip or {}}) return _json({'ok': True, 'creatures': out}) - @http.route('/yt_world/api/building/action', type='http', auth='user', + def _find_build_action(self, code, action): + """优先从 yt.world.build.item 的 actions 字段查动作配置;没有则回退 BUILD_EFFECTS。""" + item = request.env['yt.world.build.item'].sudo().search( + [('code', '=', code), ('active', '=', True)], limit=1) + if item and item.actions: + for a in item.actions: + if a.get('action') == action: + return a + return self.BUILD_EFFECTS.get((code, action)) + + def _upgrade_building_instance(self, world, code, q, r, i, j): + """在地块 micro_world 的 build_items 中定位指定建筑并升级一级。""" + tile = request.env['yt.world.tile'].sudo().search( + [('world_id', '=', world.id), ('q', '=', q), ('r', '=', r)], limit=1) + if not tile or not tile.micro_world_id: + return None + items = list(tile.micro_world_id.build_items or []) + for b in items: + if b.get('type') == code and b.get('i') == i and b.get('j') == j: + b['level'] = (b.get('level') or 1) + 1 + b['exp'] = (b.get('exp') or 0) + 50 + tile.micro_world_id.sudo().write({'build_items': items}) + return {'level': b['level'], 'exp': b['exp']} + return None + + @http.route('/yt_world/api/building/action', type='http', auth='public', methods=['POST', 'OPTIONS'], csrf=False) def building_action(self, **kw): - """执行建筑交互动作。body: {"code","action","q","r","creature_id"} - 数据驱动:查 BUILD_EFFECTS,结算金币/资源,按需回血,持久化角色。""" + """执行建筑交互动作。body: {"code","action","q","r","i","j","creature_id"} + 数据驱动:优先查数据库 actions,回退 BUILD_EFFECTS;升级会真正写入 build_items。""" + 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'}) @@ -417,10 +499,10 @@ class YtWorldApi(http.Controller): code = body.get('code') or body.get('type') action = body.get('action') creature_id = body.get('creature_id') - key = (code, action) - eff = self.BUILD_EFFECTS.get(key) + q, r, i, j = body.get('q'), body.get('r'), body.get('i'), body.get('j') + eff = self._find_build_action(code, action) if not eff: - return _json({'ok': False, 'error': 'unknown_action', 'key': list(key)}) + return _json({'ok': False, 'error': 'unknown_action', 'key': [code, action]}) char = self._get_char(world) inv = dict(char.inventory or {}) @@ -436,21 +518,114 @@ class YtWorldApi(http.Controller): if inv.get(k, 0) < v: return _json({'ok': False, 'error': 'no_res', 'res': k}) inv[k] = inv.get(k, 0) - v + # 发放收益 for k, v in (eff.get('gain') or {}).items(): if k == 'gold': gold += v else: inv[k] = inv.get(k, 0) + v + # 治疗生物 if eff.get('heal') and creature_id: cr = request.env['yt.world.creature'].sudo().search( [('id', '=', int(creature_id)), ('world_id', '=', world.id)], limit=1) if cr: cr.hp = cr.hp_max + + # 升级:持久化建筑实例状态 + upgrade_info = None + if action == 'upgrade': + if q is None or r is None or i is None or j is None: + return _json({'ok': False, 'error': 'bad_pos'}) + upgrade_info = self._upgrade_building_instance(world, code, q, r, i, j) + if not upgrade_info: + return _json({'ok': False, 'error': 'building_not_found'}) + # 持久化角色 char.sudo().write({'gold': gold, 'inventory': inv}) - return _json({ + res = { 'ok': True, 'gold': gold, 'inventory': inv, 'message': eff.get('msg', '操作成功'), - }) + } + if upgrade_info: + res.update(upgrade_info) + return _json(res) + + # ============================================================ + # 微观世界角色(玩家放置的种族角色,绑定用户持久化) + # ============================================================ + + @http.route('/yt_world/api/micro/chars', type='http', auth='public', + methods=['GET', 'OPTIONS'], csrf=False) + def micro_chars(self, **kw): + """取当前世界全部微观角色。返回 [{oid, cid, race, x, y, facing, state, tile_ref}]""" + 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'}) + recs = request.env['yt.world.micro.char'].sudo().search( + [('world_id', '=', world.id)], order='id') + out = [] + for r in recs: + out.append({ + 'oid': r.id, 'cid': r.cid, + 'race': r.race, 'x': r.x, 'y': r.y, + 'facing': r.facing, 'state': r.state, + 'hp': r.hp, + 'tile_ref': r.tile_ref or {}, + }) + return _json({'ok': True, 'chars': out}) + + @http.route('/yt_world/api/micro/chars/save', type='http', auth='public', + methods=['POST', 'OPTIONS'], csrf=False) + def save_micro_chars(self, **kw): + """upsert 微观角色列表。body: {"chars":[{cid,race,x,y,facing,state,tile_ref}], "deleted":[cid...]} + 按 cid 匹配:存在则更新,否则新建;deleted 中的 cid 记录被删除。""" + 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() + items = body.get('chars') or [] + deleted = body.get('deleted') or [] + model = request.env['yt.world.micro.char'].sudo() + out = [] + for c in items: + cid = c.get('cid') + vals = { + 'race': c.get('race') or 'human', + 'x': float(c.get('x') or 0), + 'y': float(c.get('y') or 0), + 'facing': int(c.get('facing') or 1), + 'state': c.get('state') or 'idle', + 'tile_ref': c.get('tile_ref') or {}, + } + # 生命值:传了才写(兼容旧版前端不传 hp) + if c.get('hp') is not None: + vals['hp'] = int(c.get('hp') or 0) + rec = None + if cid: + for r in model.search([('world_id', '=', world.id)]): + if r.cid == cid: + rec = r + break + if rec: + rec.write(vals) + else: + vals['world_id'] = world.id + vals['cid'] = cid + rec = model.create(vals) + out.append({'oid': rec.id, 'cid': cid}) + # 删除指定 cid + del_count = 0 + if deleted: + for r in model.search([('world_id', '=', world.id)]): + if r.cid in deleted: + r.unlink() + del_count += 1 + return _json({'ok': True, 'saved': len(out), 'deleted': del_count}) diff --git a/addons/yt_world/data/yt_world_build_item.xml b/addons/yt_world/data/yt_world_build_item.xml index 811741a..e6b85fa 100644 --- a/addons/yt_world/data/yt_world_build_item.xml +++ b/addons/yt_world/data/yt_world_build_item.xml @@ -54,6 +54,11 @@ 村民起居的基本居所,提供人口上限与夜间休息。 1120 + windmill风车 diff --git a/addons/yt_world/models/__init__.py b/addons/yt_world/models/__init__.py index a38ffa6..3541aed 100644 --- a/addons/yt_world/models/__init__.py +++ b/addons/yt_world/models/__init__.py @@ -1,6 +1,7 @@ from . import yt_world from . import yt_world_tile 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_resource diff --git a/addons/yt_world/models/yt_world_build_item.py b/addons/yt_world/models/yt_world_build_item.py index 2bf28e1..70b68e9 100644 --- a/addons/yt_world/models/yt_world_build_item.py +++ b/addons/yt_world/models/yt_world_build_item.py @@ -45,6 +45,8 @@ class YtWorldBuildItem(models.Model): stat_hp = fields.Integer(string='默认耐久', default=100) stat_yield = fields.Json(string='默认产出', default=dict, help='默认产出映射,如 {"food": 3, "wood": 1}') + actions = fields.Json(string='交互动作', default=list, + help='建筑点击弹窗中的动作列表,如 [{"action":"upgrade","label":"升级","cost":{"gold":50}}]') _sql_constraints = [ ('uniq_code', 'unique(code)', '素材编码必须唯一'), diff --git a/addons/yt_world/models/yt_world_micro_char.py b/addons/yt_world/models/yt_world_micro_char.py new file mode 100644 index 0000000..8281eeb --- /dev/null +++ b/addons/yt_world/models/yt_world_micro_char.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields + + +class YtWorldMicroChar(models.Model): + """微观世界角色实例(玩家放置的种族角色,绑定用户世界)。 + + 与建筑(build_items 存在 tile.micro JSON)不同,角色需要"绑定用户 + + 独立记录",故用独立模型:每条记录 = 一个放置在地图上的角色。 + 通过 world_id 间接绑定 res.users(一个用户一个世界)。 + cid 为前端生成的角色唯一标识,用于 upsert 匹配(参照 yt.world.creature)。 + """ + _name = 'yt.world.micro.char' + _description = '微观世界角色' + _order = 'id' + + world_id = fields.Many2one('yt.world', string='所属世界', + required=True, ondelete='cascade', index=True) + cid = fields.Char(string='客户端ID', + help='前端生成的角色唯一标识,用于增量同步') + race = fields.Char(string='种族', default='human', + help='human/demon/dwarf/elf/dragon/undead') + x = fields.Float(string='X坐标', default=0) + y = fields.Float(string='Y坐标', default=0) + facing = fields.Integer(string='朝向', default=1, help='1=右 / -1=左') + state = fields.Char(string='动作状态', default='idle', + help='idle/walk/attack/work/communicate') + hp = fields.Integer(string='生命值', default=50, + help='初始 50,被攻击扣 5,归零后角色消失') + tile_ref = fields.Json(string='所在地块', default=dict, + help='{q, r} 所在宏观地块坐标,角色按地块隔离') diff --git a/addons/yt_world/security/ir.model.access.csv b/addons/yt_world/security/ir.model.access.csv index 7a10c7a..9dbce8f 100644 --- a/addons/yt_world/security/ir.model.access.csv +++ b/addons/yt_world/security/ir.model.access.csv @@ -30,3 +30,6 @@ access_game_npc,game.npc user,model_game_npc,base.group_user,1,1,1,1 access_game_npc_relationship,game.npc.relationship user,model_game_npc_relationship,base.group_user,1,1,1,1 access_game_npc_behavior_log,game.npc.behavior.log user,model_game_npc_behavior_log,base.group_user,1,1,1,1 access_game_npc_knowledge,game.npc.knowledge user,model_game_npc_knowledge,base.group_user,1,1,1,1 +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 diff --git a/assets/buildings/default/props/well_l.png b/assets/buildings/default/props/well_l.png index b758ff7..00f3383 100644 Binary files a/assets/buildings/default/props/well_l.png and b/assets/buildings/default/props/well_l.png differ diff --git a/assets/characters.js b/assets/characters.js new file mode 100644 index 0000000..35e16fd --- /dev/null +++ b/assets/characters.js @@ -0,0 +1,917 @@ +// assets/characters.js — 微观世界角色动画系统(6种族×5动作) +'use strict'; + +// ---- 种族精灵表配置(含种族数值:移速/攻击力/最大生命/恢复能力)---- +const RACE_CONFIG = { + human: { name: '人族', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。各项能力标准,无特殊被动,适合新手。', walk: 'assets/characters/human/human_walk_1x6.png', idle_attack: 'assets/characters/human/human_idle_attack_1x6.png', work_comm: 'assets/characters/human/human_work_comm_1x6.png' }, + demon: { name: '妖族', speed: 90, attack: 5, hpMax: 50, regen: false, trait: '均衡型。无特殊被动,与人类齐平的基础战力。', walk: 'assets/characters/demon/demon_walk_1x6.png', idle_attack: 'assets/characters/demon/demon_idle_attack_1x6.png', work_comm: 'assets/characters/demon/demon_work_comm_1x6.png' }, + dwarf: { name: '矮人', speed: 72, attack: 5, hpMax: 50, regen: false, enrage: true, shieldRatio: 0.10, trait: '铁壁·狂暴:移速较慢(0.8倍);每损失10%生命攻击+5%;生命≤30%时获得10%最大生命护盾。', walk: 'assets/characters/dwarf/dwarf_walk_1x6.png', idle_attack: 'assets/characters/dwarf/dwarf_idle_attack_1x6.png', work_comm: 'assets/characters/dwarf/dwarf_work_comm_1x6.png' }, + elf: { name: '精灵', speed: 135, attack: 5, hpMax: 50, regen: false, trait: '疾风:移速飞快(1.5倍),机动性最强的种族。', walk: 'assets/characters/elf/elf_walk_1x6.png', idle_attack: 'assets/characters/elf/elf_idle_attack_1x6.png', work_comm: 'assets/characters/elf/elf_work_comm_1x6.png' }, + dragon: { name: '龙族', speed: 90, attack: 8, hpMax: 50, regen: false, trait: '龙威:攻击力高(8),爆发型输出种族。', walk: 'assets/characters/dragon/dragon_walk_1x6.png', idle_attack: 'assets/characters/dragon/dragon_idle_attack_1x6.png', work_comm: 'assets/characters/dragon/dragon_work_comm_1x6.png' }, + undead: { name: '不死族', speed: 90, attack: 4, hpMax: 100, regen: true, trait: '不死:生命极高(100);脱战10秒后每秒恢复5%最大生命。', walk: 'assets/characters/undead/undead_walk_1x6.png', idle_attack: 'assets/characters/undead/undead_idle_attack_1x6.png', work_comm: 'assets/characters/undead/undead_work_comm_1x6.png' }, +}; + +function raceStats(race) { + const rc = RACE_CONFIG[race] || RACE_CONFIG.human; + return { + speed: rc.speed, attack: rc.attack, hpMax: rc.hpMax, regen: !!rc.regen, + enrage: !!rc.enrage, shieldRatio: rc.shieldRatio || 0, + }; +} + +// 当前实际攻击力(含矮人狂暴加成) +function currentAttackOf(ch) { + let dmg = ch.attack || CHAR_ATTACK_DAMAGE; + if (ch.enrage) { + const lost = 1 - (ch.hp || 0) / (ch.hpMax || CHAR_HP_MAX); + const blocks = Math.max(0, Math.floor(lost * 10)); // 每损失 10% 为一块 + dmg = ch.attack * (1 + 0.05 * blocks); + } + return Math.round(dmg * 100) / 100; +} + +// ---- 角色状态机(每张1×6横条的帧分配)---- +const CHAR_STATES = { + idle: { sheet: 'idle_attack', base: 0, count: 3, loop: true, dur: 0.30 }, // 0-2 + walk: { sheet: 'walk', base: 0, count: 6, loop: true, dur: 0.11 }, // 0-5 + attack: { sheet: 'idle_attack', base: 3, count: 3, loop: false, dur: 0.10 }, // 3-5 + work: { sheet: 'work_comm', base: 0, count: 3, loop: false, dur: 0.11 }, // 0-2 + communicate: { sheet: 'work_comm', base: 3, count: 3, loop: false, dur: 0.13 }, // 3-5 +}; +const CHAR_CN = { idle:'待机', walk:'行走', attack:'攻击', work:'工作', communicate:'交流' }; + +const CHAR_FW = 48, CHAR_FH = 64; +const CHAR_HP_MAX = 50; // 角色初始生命值 +const CHAR_ATTACK_RANGE = 60; // 攻击范围(像素),范围内其他角色扣血 +const CHAR_ATTACK_DAMAGE = 5; // 每次攻击伤害 +const CHAR_DEATH_DUR = 1.2; // 死亡动画时长(倒地+渐隐,秒) + +// ---- 精灵表缓存 ---- +const charSheets = {}; +function loadCharSheet(race, key) { + const cacheKey = race + '_' + key; + if (charSheets[cacheKey]) return charSheets[cacheKey]; + const cfg = RACE_CONFIG[race]; + if (!cfg || !cfg[key]) return null; + const img = new Image(); + img.src = cfg[key]; + charSheets[cacheKey] = img; + return img; +} +function allSheetsLoaded() { + return Object.values(charSheets).every(img => img && img.complete && img.naturalWidth > 0); +} +function preloadAllSheets() { + Object.keys(RACE_CONFIG).forEach(race => { + ['walk','idle_attack','work_comm'].forEach(k => loadCharSheet(race, k)); + }); +} + +// ---- 角色管理 ---- +let microChars = []; +let selectedCharId = null; +let charPlaceMode = false; // 放置模式 +let charPlaceRace = 'human'; // 当前要放置的种族 + +// ---- 数据库同步(角色绑定用户世界,刷新不丢)---- +let charSaveTimer = null; +let charDeletedCids = []; // 待删除的 cid(已从本地数组移除,等后端确认) + +// ---- 障碍避让(由 world.html 注册:返回建筑占用检测 + 目标点吸附)---- +let _charAvoid = null; // { isBlocked(wx, wy): bool, findClear(wx, wy): {x,y} } +function setCharAvoid(handler) { + _charAvoid = handler || null; +} +function isCharPosBlocked(wx, wy) { + return !!( _charAvoid && _charAvoid.isBlocked(wx, wy) ); +} + +// 岛内边界(保留 12% buffer,远离岛边) +function isPosInIsland(wx, wy) { + if (typeof microCanvas === 'undefined' || !microCanvas) return true; + const mW = microCanvas.width, mH = microCanvas.height; + const cx = mW / 2, cy = mH / 2; + const Rx = mW * 0.50, Ry = mH * 0.43; + const dx = wx - cx, dy = wy - cy; + return Math.hypot(dx / Rx, dy / Ry) <= 0.85; +} +// 把任意点投影到岛内(��留 85% buffer) +function projectToIsland(wx, wy) { + if (typeof microCanvas === 'undefined' || !microCanvas) return { x: wx, y: wy }; + const mW = microCanvas.width, mH = microCanvas.height; + const cx = mW / 2, cy = mH / 2; + const Rx = mW * 0.50, Ry = mH * 0.43; + const dx = wx - cx, dy = wy - cy; + const d = Math.hypot(dx / Rx, dy / Ry); + if (d > 0.85) { + const k = 0.85 / d; + return { x: cx + dx * k, y: cy + dy * k }; + } + return { x: wx, y: wy }; +} +function findCharClearTarget(tx, ty) { + if (!_charAvoid || !_charAvoid.findClear) return { x: tx, y: ty }; + let adj = _charAvoid.findClear(tx, ty) || { x: tx, y: ty }; + // 若吸附点还在岛外/建筑上,强制投影到岛内 + if (!isPosInIsland(adj.x, adj.y)) adj = projectToIsland(adj.x, adj.y); + return adj; +} + +// 漫游意图生成(不是纯随机——有"想走走"、"想逛逛"、"想朝中心"等目标) +function generateWanderTarget(ch) { + const r = Math.random(); + for (let attempt = 0; attempt < 12; attempt++) { + let tx, ty; + if (r < 0.55) { + // 闲逛:就近 60~180px 随机方向 + const ang = Math.random() * Math.PI * 2; + const dist = 60 + Math.random() * 120; + tx = ch.x + Math.cos(ang) * dist; + ty = ch.y + Math.sin(ang) * dist; + } else if (r < 0.85) { + // 朝地图中心(让贴边角色主动回到中央,避免卡岛边) + const mW = microCanvas.width, mH = microCanvas.height; + const cx = mW / 2, cy = mH / 2; + const dx = cx - ch.x, dy = cy - ch.y; + const dist = Math.hypot(dx, dy); + if (dist > 60) { + tx = ch.x + (dx / dist) * 130; + ty = ch.y + (dy / dist) * 130; + } else { + tx = ch.x; ty = ch.y; + } + } else { + // 远行:180~320px 长距离(让漫游更有变化,避免一直小范围打转) + const ang = Math.random() * Math.PI * 2; + const dist = 180 + Math.random() * 140; + tx = ch.x + Math.cos(ang) * dist; + ty = ch.y + Math.sin(ang) * dist; + } + if (isPosInIsland(tx, ty) && !isCharPosBlocked(tx, ty)) return { tx, ty }; + } + return null; // 找不到合适位置,本轮跳过漫游 +} + +// 当前微观地块(读 world.html 的 microState) +function microCharCurrentTile() { + if (typeof microState !== 'undefined' && microState && microState.tile) { + return { q: microState.tile.q, r: microState.tile.r }; + } + return null; +} +function charTileMatches(ch, tile) { + if (!tile) return true; + const t = ch.tile || {}; + return t.q === tile.q && t.r === tile.r; +} +// 只返回"当前地块"的角色(跨地块角色不渲染/不点击) +function visibleMicroChars() { + const tile = microCharCurrentTile(); + return microChars.filter(ch => charTileMatches(ch, tile)); +} + +function _serializeChar(ch) { + return { + cid: ch.id, race: ch.race, + x: Math.round(ch.x), y: Math.round(ch.y), + facing: ch.facing, state: ch.state, + tile_ref: ch.tile || {}, + }; +} +async function saveMicroCharsNow() { + if (charSaveTimer) { clearTimeout(charSaveTimer); charSaveTimer = null; } + if (typeof API === 'undefined' || !API.saveMicroChars) return; + const chars = microChars.map(_serializeChar); + const deleted = charDeletedCids.slice(); + charDeletedCids = []; + if (!chars.length && !deleted.length) return; + const res = await API.saveMicroChars(chars, deleted); + // 失败:deleted 收回,下轮重试(chars 反正每次全量发) + if (!res || !res.ok) { + charDeletedCids = deleted.concat(charDeletedCids); + } +} +function scheduleSaveChars(delay) { + if (charSaveTimer) clearTimeout(charSaveTimer); + charSaveTimer = setTimeout(saveMicroCharsNow, delay || 300); +} + +// 从后端拉取全部微观角色,填充 microChars(带 oid/tile 映射) +async function loadMicroChars() { + if (typeof API === 'undefined' || !API.microChars) return; + const res = await API.microChars(); + if (!res || !res.ok) return; + const list = res.chars || []; + // 本地仍存在的角色(未保存成功/临时态)优先保留,其余用服务器数据重建 + const localById = {}; + microChars.forEach(ch => { if (ch.oid) localById[ch.oid] = ch; }); + microChars = list.map(c => { + const local = localById[c.oid]; + const stats = raceStats(c.race || 'human'); + return { + id: c.cid || ('char_' + Math.random().toString(36).slice(2, 10)), + oid: c.oid, race: c.race || 'human', + x: c.x, y: c.y, facing: c.facing || 1, + state: c.state || 'idle', frame: 0, frameT: 0, + targetX: c.x, targetY: c.y, + speed: stats.speed, selected: false, trail: [], + tile: c.tile_ref || {}, + hp: (typeof c.hp === 'number' && c.hp > 0) ? c.hp : stats.hpMax, + hpMax: stats.hpMax, + attack: stats.attack, + regen: stats.regen, + enrage: stats.enrage, + shieldMax: stats.shieldRatio ? Math.max(1, Math.round(stats.hpMax * stats.shieldRatio)) : 0, + shield: 0, + enrageTriggered: false, + lastHurt: 0, + hurtFlash: 0, + dying: false, + deathT: 0, + wanderT: Math.random() * 3, + wanderInterval: 2 + Math.random() * 4, + _userMove: false, + ...(local ? { frame: local.frame, frameT: local.frameT } : {}), + }; + }); + if (selectedCharId && !getChar(selectedCharId)) selectedCharId = null; + window.microChars = microChars; + updateCharPanel(); + updateCharPlacePanel(); +} + +function createChar(race, x, y) { + const id = 'char_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 5); + const stats = raceStats(race); + const ch = { + id, race, x, y, targetX: x, targetY: y, + state: 'idle', frame: 0, frameT: 0, + facing: 1, speed: stats.speed, + selected: false, placed: true, + trail: [], + oid: null, + tile: microCharCurrentTile() || {}, + hp: stats.hpMax, // 生命值(满血) + hpMax: stats.hpMax, // 最大生命(种族差异) + attack: stats.attack, // 攻击力(种族差异) + regen: stats.regen, // 是否有脱战恢复 + enrage: stats.enrage, // 是否狂暴(矮人:掉血加攻) + shieldMax: stats.shieldRatio ? Math.max(1, Math.round(stats.hpMax * stats.shieldRatio)) : 0, // 护盾上限(矮人 10% 最大生命) + shield: 0, // 当前护盾值 + enrageTriggered: false, // 护盾是否已触发(血量≤30%) + lastHurt: 0, // 上次受伤时间(ms) + hurtFlash: 0, // 受伤闪白计时 + dying: false, // 死亡动画中 + deathT: 0, // 死亡动画进度(秒) + // 自主漫游状态 + wanderT: Math.random() * 3, // 首次漫游前随机等待 + wanderInterval: 2 + Math.random() * 4, // 2~6 秒 + _userMove: false, // 是否用户指令移动 + }; + microChars.push(ch); + selectChar(id); + scheduleSaveChars(100); + return ch; +} +function getChar(id) { return microChars.find(c => c.id === id); } +function selectChar(id) { + microChars.forEach(c => { + c.selected = (c.id === id); + // 新选中的角色:若正在自主漫游(非用户指令),立即停下待命 + if (c.selected && !c._userMove && c.state === 'walk') { + c.state = 'idle'; + c.frame = 0; + c.frameT = 0; + c.wanderT = 0; + } + }); + selectedCharId = id || null; + updateCharPanel(); +} +function deleteChar(id) { + const ch = getChar(id); + if (ch) charDeletedCids.push(ch.id); + microChars = microChars.filter(c => c.id !== id); + window.microChars = microChars; + if (selectedCharId === id) { selectedCharId = null; updateCharPanel(); } + scheduleSaveChars(100); +} +function clearChars() { + microChars.forEach(ch => charDeletedCids.push(ch.id)); + microChars = []; + window.microChars = microChars; + selectedCharId = null; + updateCharPanel(); + scheduleSaveChars(100); +} + +// ---- 动作触发 ---- +function triggerCharAction(charId, action) { + const ch = getChar(charId); + if (!ch || !CHAR_STATES[action]) return false; + ch.state = action; + ch.frame = 0; + ch.frameT = 0; + if (action === 'attack') applyAttackDamage(ch); // 攻击:范围内其他角色扣血 + scheduleSaveChars(200); + return true; +} + +// 攻击:范围内(CHAR_ATTACK_RANGE)其他角色扣生命值,HP<=0 的进入倒地 +function applyAttackDamage(attacker) { + const now = Date.now(); + const damage = currentAttackOf(attacker); // 攻击力(含矮人狂暴加成) + const fmt = (v) => (v % 1 === 0 ? String(v) : String(parseFloat(v.toFixed(2)))); // 显示去尾零 + const vis = visibleMicroChars(); + for (const other of vis) { + if (other.id === attacker.id) continue; + const d = Math.hypot(other.x - attacker.x, other.y - attacker.y); + if (d <= CHAR_ATTACK_RANGE) { + let dmg = damage; + // 护盾优先吸收(矮人铁壁) + if ((other.shield || 0) > 0 && dmg > 0) { + const absorbed = Math.min(other.shield, dmg); + other.shield = Math.max(0, other.shield - absorbed); + dmg = Math.max(0, dmg - absorbed); + if (absorbed > 0) { + if (!other.dmgPopups) other.dmgPopups = []; + other.dmgPopups.push({ value: fmt(absorbed), t: 0, shield: true }); // 青色护盾吸收 + } + } + // 剩余伤害扣血 + if (dmg > 0) { + other.hp = Math.max(0, (typeof other.hp === 'number' ? other.hp : (other.hpMax || CHAR_HP_MAX)) - dmg); + other.hurtFlash = 0.4; // 受伤闪白 + other.lastHurt = now; // 记录受伤时间(脱战恢复计时用) + if (!other.dmgPopups) other.dmgPopups = []; + other.dmgPopups.push({ value: fmt(dmg), t: 0 }); // 红色扣血 + } + // 矮人铁壁:血量首次 ≤30% 时获得 10% 最大生命的护盾 + if (other.enrage && !other.enrageTriggered && other.hp > 0 && other.hp <= other.hpMax * 0.3) { + other.enrageTriggered = true; + other.shield = other.shieldMax; + if (!other.healPopups) other.healPopups = []; + other.healPopups.push({ value: other.shieldMax, t: 0, shield: true }); // 青色护盾获得 + } + } + } + // 处理 HP<=0 的角色:进入倒地死亡动画(不立即删除;动画结束后在 updateChars 移除) + microChars.forEach(c => { + if ((typeof c.hp === 'number' ? c.hp : 0) <= 0 && !c.dying) { + c.dying = true; // 死亡动画开始 + c.deathT = 0; + c.state = 'idle'; + c.hurtFlash = 0; + c.dmgPopups = []; + c.trail = []; + } + }); + if (typeof toast === 'function') toast('⚔️ 有角色生命耗尽!'); +} +function setCharTarget(charId, tx, ty) { + const ch = getChar(charId); + if (!ch) return false; + const adj = findCharClearTarget(tx, ty); // 若目标落在建筑上则吸附到最近空地 + ch.targetX = adj.x; + ch.targetY = adj.y; + ch.state = 'walk'; + ch.frame = 0; + ch.frameT = 0; + ch.trail = []; + ch.speed = raceStats(ch.race).speed; // 用户指令:按种族移速 + ch._userMove = true; + scheduleSaveChars(200); + return true; +} + +// ---- 更新角色(每帧)---- + +// WASD 八方向:返回归一化方向向量 {x,y};无按键或相反键抵消时返回 null +function wasdDirection() { + const k = (typeof window.wasdKeys !== 'undefined') ? window.wasdKeys : null; + if (!k) return null; + let x = 0, y = 0; + if (k.w) y -= 1; // 上 + if (k.s) y += 1; // 下 + if (k.a) x -= 1; // 左 + if (k.d) x += 1; // 右 + if (x === 0 && y === 0) return null; // 无按键 或 相反键抵消(W+S / A+D) + const len = Math.hypot(x, y); // 斜向归一化(对角线 0.707) + return { x: x / len, y: y / len }; +} + +function updateChars(dt) { + const deadChars = []; // 死亡动画播放完的角色,循环结束后统一移除 + visibleMicroChars().forEach(ch => { + const st = CHAR_STATES[ch.state]; + + // 死亡动画:计时推进,结束则移除(标记删除同步数据库);死亡期间冻结其他逻辑 + if (ch.dying) { + ch.deathT = (ch.deathT || 0) + dt; + if (ch.deathT >= CHAR_DEATH_DUR) { + charDeletedCids.push(ch.id); + deadChars.push(ch.id); + } + return; + } + + // 键盘 WASD 控制(仅选中角色):8 方向实时移动,优先级高于点击移动/漫游 + const kbdDir = ch.selected ? wasdDirection() : null; + if (kbdDir) { + // 键盘移动恒用种族基础移速(不受漫游/点击移动改过的 ch.speed 残留影响) + const kbdSpeed = raceStats(ch.race).speed; + ch.x += kbdDir.x * kbdSpeed * dt; + ch.y += kbdDir.y * kbdSpeed * dt; + clampCharToIsland(ch); + if (kbdDir.x < -0.1) ch.facing = 1; + else if (kbdDir.x > 0.1) ch.facing = -1; + // 目标点同步为当前位置:松开按键后自然停下(不回头走旧目标) + ch.targetX = ch.x; + ch.targetY = ch.y; + ch.state = 'walk'; + ch.speed = kbdSpeed; // 同步速度,避免松开后行走逻辑用残留慢速 + ch.wanderT = 0; + ch._userMove = true; + ch.trail.push({ x: ch.x, y: ch.y }); + if (ch.trail.length > 50) ch.trail.shift(); + } else if (ch.state === 'walk' && ch.selected && !ch._userMove) { + // 选中角色松开键盘:停住待命(不继续走旧目标) + ch.state = 'idle'; + ch.frame = 0; ch.frameT = 0; + ch.targetX = ch.x; + ch.targetY = ch.y; + } else { + // 原有漫游 / 行走逻辑 + if (!ch.selected && ch.state === 'idle') { + ch.wanderT = (ch.wanderT || 0) + dt; + const interval = ch.wanderInterval || (2 + Math.random() * 4); + if (ch.wanderT >= interval) { +ch.wanderT = 0; + ch.wanderInterval = 2 + Math.random() * 4; + // 多意图漫游目标:闲逛 / 朝中心 / 远行(带岛内+建筑校验) + const target = generateWanderTarget(ch); + if (!target) { + ch.wanderInterval = 1 + Math.random() * 2; // 找不到就短等重试 + return; + } + ch.targetX = target.tx; + ch.targetY = target.ty; + ch.state = 'walk'; + ch.frame = 0; + ch.frameT = 0; + ch.speed = 35 + Math.random() * 20; // 慢速:约全速的一半 + ch._userMove = false; + ch.trail = []; + } + } + // 行走逻辑(else 块的直接子块:键盘移动激活时不会执行) + if (ch.state === 'walk') { + const dx = ch.targetX - ch.x, dy = ch.targetY - ch.y; + const dist = Math.hypot(dx, dy); + if (dist < 3) { + ch.state = 'idle'; + ch.frame = 0; + ch.frameT = 0; + ch.wanderT = 0; // 到达后重新计时漫游 + ch._userMove = false; + scheduleSaveChars(150); // 坐标落库 + } else { + const ux = dx / dist, uy = dy / dist; + const stepX = ux * ch.speed * dt; + const stepY = uy * ch.speed * dt; + const nextX = ch.x + stepX; + const nextY = ch.y + stepY; + if (!isCharPosBlocked(nextX, nextY)) { + // 前方通畅:正常直线前进 + ch.x = nextX; + ch.y = nextY; + if (dx < -1) ch.facing = 1; + else if (dx > 1) ch.facing = -1; + ch.trail.push({x: ch.x, y: ch.y}); + if (ch.trail.length > 50) ch.trail.shift(); + } else { + // 直线被建筑挡:沿墙滑行(优先选离目标更近的一侧),绕过建筑后自动回到直线 + const slide = ch.speed * dt; + const nX = -uy, nY = ux; // 垂直方向(法线) + const dL = Math.hypot(ch.x + nX * slide - ch.targetX, ch.y + nY * slide - ch.targetY); + const dR = Math.hypot(ch.x - nX * slide - ch.targetX, ch.y - nY * slide - ch.targetY); + const dirs = dL <= dR ? [[nX, nY], [-nX, -nY]] : [[-nX, -nY], [nX, nY]]; + let slid = false; + for (const [sx, sy] of dirs) { + const cx2 = ch.x + sx * slide; + const cy2 = ch.y + sy * slide; + if (!isCharPosBlocked(cx2, cy2)) { + ch.x = cx2; + ch.y = cy2; + if (sx > 0.1) ch.facing = -1; + else if (sx < -0.1) ch.facing = 1; + ch.trail.push({x: ch.x, y: ch.y}); + if (ch.trail.length > 50) ch.trail.shift(); + slid = true; + break; + } + } + if (!slid) { + // 两侧都被挡(死角):停下,等漫游换方向 + ch.state = 'idle'; + ch.frame = 0; + ch.frameT = 0; + ch.wanderT = 0; + ch._userMove = false; + scheduleSaveChars(150); + } + } + } + // 位置钳制在浮岛椭圆内(防止角色走到地图外面) + clampCharToIsland(ch); + // 卡死检测:行走状态但位置几乎没动(被建筑/岛边卡住),强制换目标 + // 阈值按"期望移动量"的比例算——慢速种族(矮人72/漫游35)每帧位移小,固定阈值会误判 + if (ch.state === 'walk') { + const prevX = ch.prevX ?? ch.x; + const prevY = ch.prevY ?? ch.y; + const moved = Math.hypot(ch.x - prevX, ch.y - prevY); + const expected = Math.max(0.2, ch.speed * dt); // 本帧期望移动量(下限防除零) + if (moved < expected * 0.3) ch.stallFrames = (ch.stallFrames || 0) + 1; // 实际 < 期望 30% 才算卡 + else ch.stallFrames = 0; + if (ch.stallFrames > 45) { // 约 0.75 秒没移动 + ch.state = 'idle'; + ch.frame = 0; ch.frameT = 0; + ch.wanderT = 0; + ch._userMove = false; + ch.stallFrames = 0; + } + ch.prevX = ch.x; + ch.prevY = ch.y; + } else { + ch.stallFrames = 0; + } + } + } + // ---- 状态无关的每帧逻辑(行走/待机/漫游均执行)---- + // 受伤闪白计时递减 + if (ch.hurtFlash > 0) ch.hurtFlash = Math.max(0, ch.hurtFlash - dt); + // 扣血飘字计时推进并清理过期 + if (ch.dmgPopups && ch.dmgPopups.length) { + for (const p of ch.dmgPopups) p.t += dt; + ch.dmgPopups = ch.dmgPopups.filter(p => p.t < 0.9); + } + // 恢复飘字计时推进并清理过期 + if (ch.healPopups && ch.healPopups.length) { + for (const p of ch.healPopups) p.t += dt; + ch.healPopups = ch.healPopups.filter(p => p.t < 0.9); + } + // 脱战恢复:不死族,10 秒未受伤后每秒恢复 5% 最大生命,回满为止 + if (ch.regen && ch.hp > 0 && ch.hp < ch.hpMax) { + const nowMs = Date.now(); + if (!ch.lastHurt || nowMs - ch.lastHurt > 10000) { + ch.regenAcc = (ch.regenAcc || 0) + dt; // 累积计时,满 1 秒恢复一次 + if (ch.regenAcc >= 1) { + ch.regenAcc -= 1; + const heal = Math.max(1, Math.round(ch.hpMax * 0.05)); // 每次回 5%(不死 100 → +5) + ch.hp = Math.min(ch.hpMax, ch.hp + heal); + if (!ch.healPopups) ch.healPopups = []; + ch.healPopups.push({ value: heal, t: 0 }); + scheduleSaveChars(800); + } + } + } + // 动画帧推进 + ch.frameT += dt; + if (ch.frameT >= st.dur) { + ch.frameT = 0; + if (st.loop) { + ch.frame = (ch.frame + 1) % st.count; + } else if (ch.frame >= st.count - 1) { + ch.state = 'idle'; + ch.frame = 0; + } else { + ch.frame++; + } + } + }); + // 死亡动画结束:从数组移除 + if (deadChars.length) { + microChars = microChars.filter(c => !deadChars.includes(c.id)); + window.microChars = microChars; + if (selectedCharId && !getChar(selectedCharId)) { + selectedCharId = null; + updateCharPanel(); + } + scheduleSaveChars(150); // 删除落库 + } + // 选中角色属性变化(hp/护盾)时刷新右侧面板 + if (selectedCharId) { + const sel = getChar(selectedCharId); + if (sel && (sel.hp !== sel._panelHp || sel.shield !== sel._panelShield)) { + sel._panelHp = sel.hp; + sel._panelShield = sel.shield; + updateCharPanel(); + } + } +} + +// ---- 渲染角色 ---- +function renderChars(ctx, time, mW, mH) { + visibleMicroChars().forEach(ch => { + const st = CHAR_STATES[ch.state]; + + // 死亡动画:两阶段——先倒地(前50%,旋转90°、不透明),再消失(后50%,躺平渐隐) + if (ch.dying) { + const p = Math.min(1, (ch.deathT || 0) / CHAR_DEATH_DUR); + const fallP = Math.min(1, p / 0.5); // 倒地进度:前 50% 完成 0→90° 旋转 + const fadeP = Math.max(0, (p - 0.5) / 0.5); // 淡出进度:后 50% alpha 1→0 + // 倒地阴影(随倒下变横向) + ctx.save(); + ctx.globalAlpha = 0.28; + ctx.fillStyle = '#000'; + ctx.beginPath(); + ctx.ellipse(ch.x, ch.y + 2, 20 + 10 * fallP, 5 - 2 * fallP, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + // 倒地旋转精灵(倒地阶段清晰,淡出阶段渐隐) + const img = loadCharSheet(ch.race, 'idle_attack'); + if (img && img.complete && img.naturalWidth > 0) { + const fi = CHAR_STATES.idle.base + (Math.floor(ch.frame || 0) % 3); + ctx.save(); + ctx.translate(ch.x, ch.y); + ctx.rotate(fallP * Math.PI / 2); // 前 50% 旋转到底,后 50% 保持躺平 + ctx.globalAlpha = 1 - fadeP; // 后 50% 渐隐 + ctx.drawImage(img, fi * CHAR_FW, 0, CHAR_FW, CHAR_FH, -CHAR_FW / 2, -CHAR_FH * 0.9, CHAR_FW, CHAR_FH); + ctx.restore(); + } + return; // 跳过正常渲染 + } + + const img = loadCharSheet(ch.race, st.sheet); + if (!img || !img.complete || img.naturalWidth === 0) return; + const fi = st.base + ch.frame; + const drawX = ch.x - CHAR_FW * 0.5; + const drawY = ch.y - CHAR_FH * 0.90; + + // 路径拖尾(仅选中角色) + if (ch.selected && ch.trail.length > 1) { + ctx.save(); + ctx.strokeStyle = 'rgba(125,211,252,0.22)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(ch.trail[0].x, ch.trail[0].y); + for (let i = 1; i < ch.trail.length; i++) ctx.lineTo(ch.trail[i].x, ch.trail[i].y); + ctx.stroke(); + ctx.restore(); + } + + // 选中高亮光环 + if (ch.selected) { + ctx.save(); + ctx.globalAlpha = 0.25 + 0.15 * Math.sin(time * 3.5); + ctx.fillStyle = '#4af'; + ctx.beginPath(); + ctx.arc(ch.x, ch.y, 22, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + } + + // 底圈阴影 + ctx.save(); + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.beginPath(); + ctx.ellipse(ch.x, ch.y + 2, 14, 5, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + // 角色精灵 + ctx.save(); + ctx.translate(ch.x, 0); + ctx.scale(ch.facing, 1); + ctx.translate(-ch.x, 0); + ctx.drawImage(img, fi * CHAR_FW, 0, CHAR_FW, CHAR_FH, drawX, drawY, CHAR_FW, CHAR_FH); + ctx.restore(); + + // 受伤闪白(叠加半透明白) + if (ch.hurtFlash > 0) { + ctx.save(); + ctx.globalAlpha = Math.min(1, ch.hurtFlash * 2.5) * 0.55; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(drawX, drawY, CHAR_FW, CHAR_FH); + ctx.restore(); + } + + // HP 血条(头顶,常显加大;比例按种族最大生命) + const chHp = (typeof ch.hp === 'number' && ch.hp >= 0) ? ch.hp : (ch.hpMax || CHAR_HP_MAX); + const chHpMax = ch.hpMax || CHAR_HP_MAX; + { + const bw = 40, bh = 6; + const hx = ch.x - bw / 2, hy = drawY - 20; + ctx.save(); + // 背景(黑色描边 + 深色底) + ctx.fillStyle = 'rgba(0,0,0,0.75)'; + ctx.fillRect(hx - 1, hy - 1, bw + 2, bh + 2); + ctx.fillStyle = 'rgba(30,35,45,0.85)'; + ctx.fillRect(hx, hy, bw, bh); + // 血量填充 + const ratio = Math.max(0, Math.min(1, chHp / chHpMax)); + const hpColor = ratio > 0.5 ? '#3ddc6a' : ratio > 0.25 ? '#ffc93c' : '#ff3b30'; + ctx.fillStyle = hpColor; + ctx.fillRect(hx, hy, bw * ratio, bh); + // 高光(顶部细线) + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.fillRect(hx, hy, bw * ratio, 2); + ctx.restore(); + } + + // 护盾条(血条上方,青色;矮人铁壁触发后显示) + if (ch.shieldMax > 0 && ch.shield > 0) { + const bw = 40, bh = 4; + const hx = ch.x - bw / 2, hy = drawY - 27; + ctx.save(); + ctx.fillStyle = 'rgba(0,0,0,0.65)'; + ctx.fillRect(hx - 1, hy - 1, bw + 2, bh + 2); + ctx.fillStyle = 'rgba(16,44,64,0.9)'; + ctx.fillRect(hx, hy, bw, bh); + const sRatio = Math.max(0, Math.min(1, ch.shield / ch.shieldMax)); + ctx.fillStyle = '#37c7f0'; + ctx.fillRect(hx, hy, bw * sRatio, bh); + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + ctx.fillRect(hx, hy, bw * sRatio, 2); + ctx.restore(); + } + + // 扣血飘字(-N 向上飘动渐隐;红色=扣血,青色=护盾吸收) + if (ch.dmgPopups && ch.dmgPopups.length) { + ctx.save(); + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; + for (const p of ch.dmgPopups) { + const life = 1 - p.t / 0.9; + if (life <= 0) continue; + ctx.globalAlpha = Math.min(1, life); + ctx.fillStyle = p.shield ? '#37c7f0' : '#ff5040'; + ctx.fillText('-' + p.value, ch.x, ch.y - 46 - p.t * 26); + } + ctx.restore(); + } + + // 恢复飘字(+N 向上飘动渐隐;绿色=回血,青色=护盾获得) + if (ch.healPopups && ch.healPopups.length) { + ctx.save(); + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'center'; + for (const p of ch.healPopups) { + const life = 1 - p.t / 0.8; + if (life <= 0) continue; + ctx.globalAlpha = Math.min(1, life); + ctx.fillStyle = p.shield ? '#37c7f0' : '#46e06a'; + ctx.fillText('+' + p.value, ch.x, ch.y - 40 - p.t * 20); + } + ctx.restore(); + } + + // 名字标签 + ctx.save(); + const name = RACE_CONFIG[ch.race].name; + ctx.font = 'bold 11px sans-serif'; + const tw = ctx.measureText(name).width; + const labelY = drawY - 8; + ctx.fillStyle = 'rgba(8,12,26,0.78)'; + ctx.fillRect(ch.x - tw/2 - 4, labelY - 8, tw + 8, 16); + ctx.fillStyle = '#e0e0e0'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(name, ch.x, labelY); + // 状态小点 + ctx.fillStyle = ch.state === 'walk' ? '#4af' : ch.state === 'attack' ? '#f44' : ch.state === 'work' ? '#8bc34a' : ch.state === 'communicate' ? '#e7b85c' : '#8899aa'; + ctx.beginPath(); + ctx.arc(ch.x + tw/2 + 8, labelY, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + }); +} + +// ---- 点击检测(世界坐标)---- +function hitChar(wx, wy) { + const vis = visibleMicroChars(); + for (let i = vis.length - 1; i >= 0; i--) { + const ch = vis[i]; + const dx = wx - ch.x, dy = wy - (ch.y - CHAR_FH * 0.5); + if (Math.hypot(dx, dy) < 28) return ch; + } + return null; +} + +// ---- 限制在浮岛椭圆内 ---- +function clampCharToIsland(ch) { + if (typeof microCanvas === 'undefined' || !microCanvas) return; + const mW = microCanvas.width, mH = microCanvas.height; + const cx = mW / 2, cy = mH / 2; + const Rx = mW * 0.50, Ry = mH * 0.43; + const dx = ch.x - cx, dy = ch.y - cy; + const d = Math.hypot(dx / Rx, dy / Ry); + if (d > 0.92) { + const k = 0.92 / d; + ch.x = cx + dx * k; + ch.y = cy + dy * k; + } +} + +// ---- UI 面板 ---- +function updateCharPanel() { + const panel = document.getElementById('charPanel'); + if (!panel) return; + const ch = selectedCharId ? getChar(selectedCharId) : null; + if (!ch) { + panel.innerHTML = ''; + panel.style.display = 'none'; + return; + } + panel.style.display = 'block'; + const rc = RACE_CONFIG[ch.race] || RACE_CONFIG.human; + const stats = raceStats(ch.race); + const fmt = (v) => (v % 1 === 0 ? String(v) : String(parseFloat(v.toFixed(2)))); + // 血量比例 + 颜色 + const hpRatio = Math.max(0, Math.min(1, ch.hp / (ch.hpMax || CHAR_HP_MAX))); + const hpColor = hpRatio > 0.5 ? '#3ddc6a' : hpRatio > 0.25 ? '#ffc93c' : '#ff3b30'; + // 当前攻击(含矮人狂暴加成) + const curAtk = currentAttackOf(ch); + const atkBonus = (ch.enrage && curAtk > ch.attack) + ? ` (+${fmt(curAtk - ch.attack)} 狂暴)` : ''; + // 护盾(矮人:显示当前值,未触发提示) + const shieldHtml = ch.shieldMax > 0 + ? `
🛡 护盾 ${ch.shield}/${ch.shieldMax}${ch.shield > 0 ? '' : '(未触发)'}
` + : ''; + const actions = ['idle', 'attack', 'work', 'communicate']; + const btns = actions.map(a => { + const active = ch.state === a ? 'background:#2a4a6a;border-color:#5a9acc;' : ''; + return ``; + }).join(''); + panel.innerHTML = ` +
${rc.name} · ${CHAR_CN[ch.state]}
+
❤ 生命 ${Math.round(ch.hp)}/${ch.hpMax}
+
+
+
+ ${shieldHtml} +
👟 移速 ${stats.speed}
+
⚔ 攻击 ${fmt(curAtk)}${atkBonus}
+
📖 ${rc.trait || '暂无介绍'}
+
${btns}
+
💡 点击地图移动 · WASD 方向键控制
+
+ +
+ `; + panel.querySelectorAll('.charActBtn').forEach(btn => { + btn.addEventListener('click', () => { triggerCharAction(ch.id, btn.dataset.act); updateCharPanel(); }); + }); + panel.querySelector('#charDelBtn').addEventListener('click', () => deleteChar(ch.id)); +} + +// ---- 放置面板 ---- +function updateCharPlacePanel() { + const panel = document.getElementById('charPlacePanel'); + if (!panel) return; + const races = Object.keys(RACE_CONFIG); + panel.innerHTML = ` +
放置角色
+
+ ${races.map(r => ``).join('')} +
+
+ + +
+ `; + panel.querySelectorAll('.charRaceBtn').forEach(btn => { + btn.addEventListener('click', () => { charPlaceRace = btn.dataset.race; charPlaceMode = true; updateCharPlacePanel(); }); + }); + panel.querySelector('#charPlaceToggle').addEventListener('click', () => { charPlaceMode = !charPlaceMode; updateCharPlacePanel(); }); + panel.querySelector('#charClearAll').addEventListener('click', () => { clearChars(); updateCharPlacePanel(); }); +} + +// 导出到 window +window.createChar = createChar; +window.selectChar = selectChar; +window.deleteChar = deleteChar; +window.triggerCharAction = triggerCharAction; +window.setCharTarget = setCharTarget; +window.getChar = getChar; +window.updateChars = updateChars; +window.renderChars = renderChars; +window.hitChar = hitChar; +window.clampCharToIsland = clampCharToIsland; +window.updateCharPanel = updateCharPanel; +window.updateCharPlacePanel = updateCharPlacePanel; +window.allSheetsLoaded = allSheetsLoaded; +window.loadCharSheet = loadCharSheet; +window.preloadAllSheets = preloadAllSheets; +window.microChars = microChars; +window.charSheets = charSheets; +window.charPlaceMode = charPlaceMode; +window.charPlaceRace = charPlaceRace; +window.CHAR_STATES = CHAR_STATES; +window.CHAR_CN = CHAR_CN; +window.RACE_CONFIG = RACE_CONFIG; +window.loadMicroChars = loadMicroChars; +window.saveMicroCharsNow = saveMicroCharsNow; +window.scheduleSaveChars = scheduleSaveChars; +window.visibleMicroChars = visibleMicroChars; +window.setCharAvoid = setCharAvoid; diff --git a/assets/characters/demon/demon_idle_attack_1x6.png b/assets/characters/demon/demon_idle_attack_1x6.png new file mode 100644 index 0000000..a1a24cd Binary files /dev/null and b/assets/characters/demon/demon_idle_attack_1x6.png differ diff --git a/assets/characters/demon/demon_walk_1x6.png b/assets/characters/demon/demon_walk_1x6.png new file mode 100644 index 0000000..4bb7373 Binary files /dev/null and b/assets/characters/demon/demon_walk_1x6.png differ diff --git a/assets/characters/demon/demon_work_comm_1x6.png b/assets/characters/demon/demon_work_comm_1x6.png new file mode 100644 index 0000000..899578b Binary files /dev/null and b/assets/characters/demon/demon_work_comm_1x6.png differ diff --git a/assets/characters/demon/references/ref_demon.png b/assets/characters/demon/references/ref_demon.png new file mode 100644 index 0000000..5a81ec4 Binary files /dev/null and b/assets/characters/demon/references/ref_demon.png differ diff --git a/assets/characters/dragon/dragon_idle_attack_1x6.png b/assets/characters/dragon/dragon_idle_attack_1x6.png new file mode 100644 index 0000000..82bb794 Binary files /dev/null and b/assets/characters/dragon/dragon_idle_attack_1x6.png differ diff --git a/assets/characters/dragon/dragon_walk_1x6.png b/assets/characters/dragon/dragon_walk_1x6.png new file mode 100644 index 0000000..6fc257a Binary files /dev/null and b/assets/characters/dragon/dragon_walk_1x6.png differ diff --git a/assets/characters/dragon/dragon_work_comm_1x6.png b/assets/characters/dragon/dragon_work_comm_1x6.png new file mode 100644 index 0000000..f5cd915 Binary files /dev/null and b/assets/characters/dragon/dragon_work_comm_1x6.png differ diff --git a/assets/characters/dragon/references/ref_dragon.png b/assets/characters/dragon/references/ref_dragon.png new file mode 100644 index 0000000..5bc34df Binary files /dev/null and b/assets/characters/dragon/references/ref_dragon.png differ diff --git a/assets/characters/dwarf/dwarf_idle_attack_1x6.png b/assets/characters/dwarf/dwarf_idle_attack_1x6.png new file mode 100644 index 0000000..9244631 Binary files /dev/null and b/assets/characters/dwarf/dwarf_idle_attack_1x6.png differ diff --git a/assets/characters/dwarf/dwarf_walk_1x6.png b/assets/characters/dwarf/dwarf_walk_1x6.png new file mode 100644 index 0000000..4b9e16b Binary files /dev/null and b/assets/characters/dwarf/dwarf_walk_1x6.png differ diff --git a/assets/characters/dwarf/dwarf_work_comm_1x6.png b/assets/characters/dwarf/dwarf_work_comm_1x6.png new file mode 100644 index 0000000..942d03c Binary files /dev/null and b/assets/characters/dwarf/dwarf_work_comm_1x6.png differ diff --git a/assets/characters/dwarf/references/ref_dwarf.png b/assets/characters/dwarf/references/ref_dwarf.png new file mode 100644 index 0000000..6abd4a2 Binary files /dev/null and b/assets/characters/dwarf/references/ref_dwarf.png differ diff --git a/assets/characters/elf/elf_idle_attack_1x6.png b/assets/characters/elf/elf_idle_attack_1x6.png new file mode 100644 index 0000000..5245850 Binary files /dev/null and b/assets/characters/elf/elf_idle_attack_1x6.png differ diff --git a/assets/characters/elf/elf_walk_1x6.png b/assets/characters/elf/elf_walk_1x6.png new file mode 100644 index 0000000..e655a79 Binary files /dev/null and b/assets/characters/elf/elf_walk_1x6.png differ diff --git a/assets/characters/elf/elf_work_comm_1x6.png b/assets/characters/elf/elf_work_comm_1x6.png new file mode 100644 index 0000000..c4da278 Binary files /dev/null and b/assets/characters/elf/elf_work_comm_1x6.png differ diff --git a/assets/characters/elf/references/ref_elf.png b/assets/characters/elf/references/ref_elf.png new file mode 100644 index 0000000..92064a3 Binary files /dev/null and b/assets/characters/elf/references/ref_elf.png differ diff --git a/assets/characters/human/human_idle_attack_1x6.png b/assets/characters/human/human_idle_attack_1x6.png new file mode 100644 index 0000000..aca3d96 Binary files /dev/null and b/assets/characters/human/human_idle_attack_1x6.png differ diff --git a/assets/characters/human/human_walk_1x6.png b/assets/characters/human/human_walk_1x6.png new file mode 100644 index 0000000..f98927c Binary files /dev/null and b/assets/characters/human/human_walk_1x6.png differ diff --git a/assets/characters/human/human_work_comm_1x6.png b/assets/characters/human/human_work_comm_1x6.png new file mode 100644 index 0000000..532e167 Binary files /dev/null and b/assets/characters/human/human_work_comm_1x6.png differ diff --git a/assets/characters/human/references/ref_human.png b/assets/characters/human/references/ref_human.png new file mode 100644 index 0000000..541312a Binary files /dev/null and b/assets/characters/human/references/ref_human.png differ diff --git a/assets/characters/undead/references/ref_undead.png b/assets/characters/undead/references/ref_undead.png new file mode 100644 index 0000000..baecf4d Binary files /dev/null and b/assets/characters/undead/references/ref_undead.png differ diff --git a/assets/characters/undead/undead_idle_attack_1x6.png b/assets/characters/undead/undead_idle_attack_1x6.png new file mode 100644 index 0000000..274f48a Binary files /dev/null and b/assets/characters/undead/undead_idle_attack_1x6.png differ diff --git a/assets/characters/undead/undead_walk_1x6.png b/assets/characters/undead/undead_walk_1x6.png new file mode 100644 index 0000000..cc8e8ee Binary files /dev/null and b/assets/characters/undead/undead_walk_1x6.png differ diff --git a/assets/characters/undead/undead_work_comm_1x6.png b/assets/characters/undead/undead_work_comm_1x6.png new file mode 100644 index 0000000..c3e9a37 Binary files /dev/null and b/assets/characters/undead/undead_work_comm_1x6.png differ diff --git a/assets/game-api.js b/assets/game-api.js index bc3cd3a..487f6ba 100644 --- a/assets/game-api.js +++ b/assets/game-api.js @@ -225,6 +225,30 @@ const API = { return { ok: false, error: 'network', detail: String(e && e.message || e) }; } }, + // ---------- 微观世界角色(玩家放置的种族角色,绑定用户持久化) ---------- + async microChars() { + try { + const r = await fetch(_worldUrl('/yt_world/api/micro/chars'), { credentials: _wcred }); + if (!r.ok) return { ok: false, error: 'http_' + r.status }; + 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 saveMicroChars(chars, deleted) { + try { + const r = await fetch(_worldUrl('/yt_world/api/micro/chars/save'), { + method: 'POST', credentials: _wcred, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chars: chars || [], deleted: deleted || [] }), + }); + const j = await r.json(); + return j && j.ok === false ? j : (j || { ok: true }); + } catch (e) { + return { ok: false, error: 'network', detail: String(e && e.message || e) }; + } + }, // ---------- 图鉴(codex) ---------- async codexResources() { return (await fetch(_worldUrl('/yt_world/api/codex/resources'), { credentials: _wcred })).json(); diff --git a/world.html b/world.html index 9a7e073..379cce6 100644 --- a/world.html +++ b/world.html @@ -899,6 +899,11 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
人口
+
+
角色
+
+ +
@@ -1903,6 +1908,7 @@ async function microLoadCatalog() { if (it.statLevel) t.statLevel = it.statLevel; if (it.statHp) t.statHp = it.statHp; if (it.statYield && Object.keys(it.statYield).length) t.statYield = it.statYield; + if (Array.isArray(it.actions) && it.actions.length) t.actions = it.actions; // 后端动作优先 }); microCatalogLoaded = true; } catch (e) { /* 未登录/网络不可达:保留兜底常量 */ } @@ -3045,6 +3051,39 @@ function enterMicroWorld(tile) { microLoadIcons(); // 进入微观视图即预加载建造贴图,避免已放置建筑先显示文字 microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效 microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态 + // 初始化角色动画系统 + if (typeof preloadAllSheets === 'function') preloadAllSheets(); + if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel(); + if (typeof updateCharPanel === 'function') updateCharPanel(); + if (typeof loadMicroChars === 'function') loadMicroChars(); // 从后端拉取该用户放置的角色(刷新/重进不丢) + // 注册角色障碍避让:随机游走 / 用户点击都被吸附到非建筑空地 + if (typeof setCharAvoid === 'function') { + setCharAvoid({ + isBlocked: (wx, wy) => { + const sp = worldToScreen(wx, wy); + const cell = microCellAt(sp); + return !!(cell && microBuildingAt(cell)); + }, + // 螺旋搜索最近空地:从目标点向外 16 方向 × 12 圈,最多 192 个候选点 + findClear: (wx, wy) => { + const isFree = (x, y) => { + const sp = worldToScreen(x, y); + const cell = microCellAt(sp); + return !(!cell || microBuildingAt(cell)); + }; + for (let r = 0; r < 12; r++) { + for (let i = 0; i < 16; i++) { + const ang = (i / 16) * Math.PI * 2; + const rad = r * 6; + const x = wx + Math.cos(ang) * rad; + const y = wy + Math.sin(ang) * rad; + if (isFree(x, y)) return { x, y }; + } + } + return { x: wx, y: wy }; // 实在找不到就原样返回(极端兜底) + }, + }); + } initMicroCanvas(); startMicroRender(); microSanitizeBuildings(); // 进入即清理本地块越界建筑(登录期 microState 未建,setMicroTile 不跑初始地块) @@ -3128,10 +3167,16 @@ function startMicroRender() { function loop() { if (!microState) return; microAnimId = requestAnimationFrame(loop); - microState.animTime += 0.016; + const now = performance.now(); + const dt = Math.min((now - (loop._last || now)) / 1000, 0.05); + loop._last = now; + microState.animTime += dt; const mW = microCanvas.width, mH = microCanvas.height; const terrain = TERRAIN[microState.tile.terrain]; + // ---- 更新角色动画与位置 ---- + if (typeof updateChars === 'function') updateChars(dt); + // 建造面板展开时:世界整体上移 + 等比缩小,腾出底部给面板(仅改变换,画布尺寸不变 → 无闪烁) const boTarget = microBuildMode ? 1 : 0; microBuildOpenT += (boTarget - microBuildOpenT) * 0.18; @@ -3384,6 +3429,9 @@ function startMicroRender() { microCtx.globalAlpha = 1; } + // ---- 渲染角色(精灵图动画)---- + if (typeof renderChars === 'function') renderChars(microCtx, microState.animTime, mW, mH); + // 大气粒子层(背景图模式跳过,保持纯净背景图) if (!hasBgImage) drawMicroAtmoParticles(microCtx, mW, mH, terrain); @@ -3426,6 +3474,15 @@ function screenToWorld(sx, sy) { y: (sy - mH / 2 - microPanY + microBuildLift) / s + mH / 2 }; } +// 将世界坐标还原为屏幕坐标(screenToWorld 的逆),用于角色碰撞检测 +function worldToScreen(wx, wy) { + const mW = microCanvas.width, mH = microCanvas.height; + const s = microZoom * microBuildScale; + return { + x: (wx - mW / 2) * s + mW / 2 + microPanX, + y: (wy - mH / 2) * s + mH / 2 + microPanY - microBuildLift, + }; +} // 平移限位:像缩放下限一样,浮岛不能拖出视野 // zoom=1 时限位为 0(浮岛居中、不可平移);zoom>1 时允许平移到「浮岛边缘抵达屏幕边」为止 function clampMicroPan() { @@ -3518,15 +3575,49 @@ function onMicroDown(e) { } return; } - // 中键/右键 → 进入平移模式;左键 → 切地块滑动 - if (e.button === 1 || e.button === 2) { // 中键 or 右键 + // 中键 → 纯平移;右键 → 单击取消选中角色,按住拖拽仍是平移 + if (e.button === 1) { e.preventDefault(); microPanning = true; microPanStart = getMicroPoint(e); microCanvas.style.cursor = 'move'; return; // 不启动 swipe } + if (e.button === 2) { + e.preventDefault(); + // 右键单击 = 取消选中角色(RTS 习惯) + if (typeof selectChar === 'function' && typeof selectedCharId !== 'undefined' && selectedCharId) { + selectChar(null); + } + microPanning = true; // 右键拖拽仍可平移视图 + microPanStart = getMicroPoint(e); + microCanvas.style.cursor = 'move'; + return; + } const p = getMicroPoint(e); + // 角色交互优先(非建造模式、左键) + if (!microBuildMode && e.button === 0 && typeof hitChar === 'function') { + const w = screenToWorld(p.x, p.y); + const ch = hitChar(w.x, w.y); + if (ch) { + // 点击已选中的角色 → 取消选中;点击其他角色 → 切换选中 + if (typeof selectedCharId !== 'undefined' && selectedCharId === ch.id) selectChar(null); + else selectChar(ch.id); + e.preventDefault(); return; + } + // 放置模式:点击地面放置角色(优先级高于移动) + if (typeof charPlaceMode !== 'undefined' && charPlaceMode && typeof createChar === 'function') { + createChar(charPlaceRace, w.x, w.y); + charPlaceMode = false; + if (typeof updateCharPlacePanel === 'function') updateCharPlacePanel(); + return; + } + // 选中角色后:点击地面直接移动(无需再点按钮) + if (typeof selectedCharId !== 'undefined' && selectedCharId && typeof setCharTarget === 'function') { + setCharTarget(selectedCharId, w.x, w.y); + return; + } + } // 放大状态下(zoom>1)左键拖拽 = 平移查看浮岛内部细节; // 仅初始大小(zoom=1)时左键拖拽才是滑动切相邻地块 if (microZoom > 1.001) { @@ -4269,7 +4360,10 @@ function showBuildModal(b) { if (b.exp == null) b.exp = 0; if (b.hp == null) b.hp = it.statHp || 100; microOpenBuilding = b; - const cfg = BUILD_INTERACT[b.type] || { title: it.name, tagline: it.desc || '', actions: [] }; + // 优先用后端目录(micro_catalog)同步来的动作;没有则回退前端兜底 BUILD_INTERACT + const cfg = (it.actions && it.actions.length) + ? { title: it.name, tagline: it.desc || '', actions: it.actions } + : (BUILD_INTERACT[b.type] || { title: it.name, tagline: it.desc || '', actions: [] }); const name = it.name || b.type; const img = (typeof it.icon === 'string') ? `` : ''; @@ -4281,10 +4375,11 @@ function showBuildModal(b) { const hpPct = Math.max(0, Math.min(100, Math.round(b.hp / (it.statHp || 100) * 100))); const hpColor = hpPct > 50 ? '#8bc34a' : hpPct > 25 ? '#ff9800' : '#f44336'; const acts = (cfg.actions || []).map(a => - `
${a.icon} ${a.label}
${a.desc}
` + `
${a.icon} ${a.label}
${a.desc}
` ).join(''); document.getElementById('modalTitle').textContent = `${name} · Lv.${b.level}`; - document.getElementById('modalBody').innerHTML = ` + const body = document.getElementById('modalBody'); + body.innerHTML = `
${img}
${it.desc ? `
${it.desc}
` : ''}
类别${microCatName(it.cat)}
@@ -4296,6 +4391,11 @@ function showBuildModal(b) {
${acts || '
暂无可交互动作
'}
`; + body.querySelectorAll('.cmdBtn').forEach(btn => { + btn.addEventListener('click', () => { + microBuildingAct(btn.dataset.btype, btn.dataset.baction, btn.dataset.blabel); + }); + }); document.getElementById('unitModal').classList.add('active'); } @@ -4303,7 +4403,8 @@ function showBuildModal(b) { async function microBuildingAct(type, action, label) { const tile = microState && microState.tile; if (!tile) return; - const res = await API.buildingAction({ code: type, action, q: tile.q, r: tile.r }); + const bpos = microOpenBuilding || {}; + const res = await API.buildingAction({ code: type, action, q: tile.q, r: tile.r, i: bpos.i, j: bpos.j }); if (!res || res.ok === false) { const msg = res && res.error === 'no_gold' ? '金币不足' : (res && res.error === 'no_res' ? ('资源不足:' + (res.res || '')) : '操作失败(未登录或网络异常)'); @@ -4313,10 +4414,10 @@ 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(); - // 升级:抬高该建筑等级并持久化 - if (action === 'upgrade' && microOpenBuilding) { - microOpenBuilding.level = (microOpenBuilding.level || 1) + 1; - microOpenBuilding.exp = (microOpenBuilding.exp || 0) + 50; + // 升级:用后端落库后的权威等级/经验回写并持久化 + if (action === 'upgrade' && microOpenBuilding && res.level != null) { + microOpenBuilding.level = res.level; + microOpenBuilding.exp = res.exp; scheduleBuildSave(); } // 治疗:回满本村所有生物生命 @@ -5105,9 +5206,27 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') exitMicroWor document.getElementById('backBtn').addEventListener('click', exitMicroWorld); document.getElementById('unitModal').addEventListener('click', e => { if (e.target === document.getElementById('unitModal')) closeUnitModal(); }); +// WASD 方向键:选中角色后控制移动(8 方向,相反键抵消) +const wasdKeys = { w: false, a: false, s: false, d: false }; +window.addEventListener('keydown', e => { + const k = e.key.toLowerCase(); + if (k === 'w' || k === 'a' || k === 's' || k === 'd') { + const tag = (e.target && e.target.tagName || '').toLowerCase(); + if (tag === 'input' || tag === 'textarea') return; // 输入框聚焦时不响应 + wasdKeys[k] = true; + e.preventDefault(); // 阻止页面滚动 + } +}); +window.addEventListener('keyup', e => { + const k = e.key.toLowerCase(); + if (k in wasdKeys) wasdKeys[k] = false; +}); +window.wasdKeys = wasdKeys; + function exitMicroWorld() { document.getElementById('microView').classList.remove('active'); if (microAnimId) { cancelAnimationFrame(microAnimId); microAnimId = null; } + if (typeof saveMicroCharsNow === 'function') saveMicroCharsNow(); // 退出前落库角色(删除/移动) microState = null; microGridCache = null; microBuildRevealAt = 0; @@ -5388,7 +5507,7 @@ function microCommitPlace() { const g = microComputeGhost(); if (!g || !g.valid) { microToast('这里不能建造(红色区域)'); return; } const it = microBuildItemById(microBuildSel); - microBuildList().push({ i: g.i, j: g.j, type: microBuildSel, rot: microBuildRot, w: it.w || 1, h: it.h || 1 }); + 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) }); scheduleBuildSave(); } // 右键:命中建筑→删除;否则取消当前选择/编辑 @@ -6247,6 +6366,7 @@ window.addEventListener('keydown', e => { if (typeof fitWorld === 'function') fitWorld(); +