why_ga #1

Merged
Ghost merged 4 commits from why_ga into main 2026-08-16 15:53:20 +08:00
35 changed files with 1321 additions and 43 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
'name': '宇森世界 - 沙盘与微观世界',
'version': '0.2',
'version': '0.3',
'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)',
'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。',
'category': 'Game',
+205 -30
View File
@@ -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})
@@ -54,6 +54,11 @@
<field name="desc">村民起居的基本居所,提供人口上限与夜间休息。</field>
<field name="stat_level">1</field><field name="stat_hp">120</field>
<field name="stat_yield" eval="{'population': 4}"/>
<field name="actions" eval="[
{'action': 'employ', 'label': '发布任职', 'icon': '📜', 'desc': '发布村长任职委托,吸引居民就任', 'cost': {}, 'gain': {}, 'msg': '已发布村长任职委托'},
{'action': 'recruit', 'label': '招募村民', 'icon': '🧑', 'desc': '招募新村民,人口 +1', 'cost': {}, 'gain': {'gold': 5}, 'msg': '招募村民,人口+1'},
{'action': 'upgrade', 'label': '升级', 'icon': '⬆️', 'desc': '提升农舍等级,扩大人口上限', 'cost': {'gold': 50}, 'gain': {}, 'msg': '农舍升级申请已提交'}
]"/>
</record>
<record id="bi_windmill" model="yt.world.build.item">
<field name="code">windmill</field><field name="name">风车</field>
+1
View File
@@ -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
@@ -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)', '素材编码必须唯一'),
@@ -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} 所在宏观地块坐标,角色按地块隔离')
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
30 access_game_npc_relationship game.npc.relationship user model_game_npc_relationship base.group_user 1 1 1 1
31 access_game_npc_behavior_log game.npc.behavior.log user model_game_npc_behavior_log base.group_user 1 1 1 1
32 access_game_npc_knowledge game.npc.knowledge user model_game_npc_knowledge base.group_user 1 1 1 1
33 access_yt_world_micro_char_user yt.world.micro.char user model_yt_world_micro_char base.group_user 1 1 1 1
34 access_yt_world_micro_char_public yt.world.micro.char public model_yt_world_micro_char base.group_public 1 0 0 0
35 access_yt_world_micro_char_portal yt.world.micro.char portal model_yt_world_micro_char base.group_portal 1 0 0 0
Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 71 KiB

+917
View File
@@ -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)
? ` <span style="color:#ffb454;">(+${fmt(curAtk - ch.attack)} 狂暴)</span>` : '';
// 护盾(矮人:显示当前值,未触发提示)
const shieldHtml = ch.shieldMax > 0
? `<div style="margin-top:5px;color:#8a9aaa;font-size:11px;">🛡 护盾 <b style="color:#37c7f0;">${ch.shield}</b>/${ch.shieldMax}<span style="color:#667;">${ch.shield > 0 ? '' : '(未触发)'}</span></div>`
: '';
const actions = ['idle', 'attack', 'work', 'communicate'];
const btns = actions.map(a => {
const active = ch.state === a ? 'background:#2a4a6a;border-color:#5a9acc;' : '';
return `<button class="charActBtn" data-act="${a}" style="${active}background:#1d2b4a;color:#cde;border:1px solid #3a5a8e;padding:5px 10px;border-radius:5px;cursor:pointer;font-size:12px;transition:background .15s;">${CHAR_CN[a]}</button>`;
}).join('');
panel.innerHTML = `
<div style="font-size:13px;font-weight:bold;color:#7dd3fc;margin-bottom:8px;">${rc.name} · ${CHAR_CN[ch.state]}</div>
<div style="font-size:11px;color:#8a9aaa;margin-bottom:3px;">❤ 生命 <b style="color:#e8eef7;">${Math.round(ch.hp)}</b>/${ch.hpMax}</div>
<div style="background:#0d1526;border-radius:3px;padding:1px;height:8px;">
<div style="width:${(hpRatio * 100).toFixed(1)}%;height:100%;background:${hpColor};border-radius:2px;transition:width .2s;"></div>
</div>
${shieldHtml}
<div style="margin-top:6px;color:#8a9aaa;font-size:11px;">👟 移速 <b style="color:#e8eef7;">${stats.speed}</b></div>
<div style="margin-top:5px;color:#8a9aaa;font-size:11px;">⚔ 攻击 <b style="color:#e8eef7;">${fmt(curAtk)}</b>${atkBonus}</div>
<div style="margin-top:8px;padding:6px 8px;background:#16213a;border-radius:5px;color:#9fb2cc;font-size:11px;line-height:1.5;">📖 ${rc.trait || '暂无介绍'}</div>
<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;">${btns}</div>
<div style="margin-top:8px;color:#8a9aaa;font-size:11px;">💡 点击地图移动 · WASD 方向键控制</div>
<div style="margin-top:6px;">
<button id="charDelBtn" style="background:#4a1e1e;color:#faa;border:1px solid #8a3a3a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;">删除</button>
</div>
`;
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 = `
<div style="font-size:12px;color:#8a9aaa;margin-bottom:6px;">放置角色</div>
<div style="display:flex;gap:6px;flex-wrap:wrap;">
${races.map(r => `<button class="charRaceBtn" data-race="${r}" style="${charPlaceRace===r?'background:#2a4a6a;border-color:#5a9acc;':''}background:#1d2b4a;color:#cde;border:1px solid #3a5a8e;padding:5px 10px;border-radius:5px;cursor:pointer;font-size:12px;">${RACE_CONFIG[r].name}</button>`).join('')}
</div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button id="charPlaceToggle" style="background:${charPlaceMode?'#2a4a6a':'#1d2b4a'};color:#cde;border:1px solid #3a5a8e;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;">${charPlaceMode?'取消放置':'开始放置'}</button>
<button id="charClearAll" style="background:#4a1e1e;color:#faa;border:1px solid #8a3a3a;padding:4px 10px;border-radius:5px;cursor:pointer;font-size:11px;">清空全部</button>
</div>
`;
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;
Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 932 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 828 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1006 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 868 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

+24
View File
@@ -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();
+132 -12
View File
@@ -899,6 +899,11 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<div class="panel-label">人口</div>
<div id="detailPopulation"></div>
</div>
<div class="panel-section">
<div class="panel-label">角色</div>
<div id="charPlacePanel" style="margin-bottom:8px;"></div>
<div id="charPanel" style="display:none;padding:8px;background:rgba(100,170,255,0.06);border:1px solid rgba(100,170,255,0.18);border-radius:6px;"></div>
</div>
</div>
</div>
<div id="microBottomBar">
@@ -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')
? `<img src="${it.icon}" alt="" style="width:64px;height:64px;object-fit:contain;margin:2px auto 10px;display:block;">` : '';
@@ -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 =>
`<div class="cmdBtn" onclick="microBuildingAct(${JSON.stringify(b.type)}, '${a.action}', '${a.label}')">${a.icon} ${a.label}<div style="font-size:10px;opacity:.7;margin-top:2px;white-space:normal;">${a.desc}</div></div>`
`<div class="cmdBtn" data-btype="${b.type}" data-baction="${a.action}" data-blabel="${a.label}">${a.icon} ${a.label}<div style="font-size:10px;opacity:.7;margin-top:2px;white-space:normal;">${a.desc}</div></div>`
).join('');
document.getElementById('modalTitle').textContent = `${name} · Lv.${b.level}`;
document.getElementById('modalBody').innerHTML = `
const body = document.getElementById('modalBody');
body.innerHTML = `
<div style="text-align:center">${img}</div>
${it.desc ? `<div style="color:rgba(234,242,255,0.78);font-size:12px;line-height:1.5;margin:0 0 12px;">${it.desc}</div>` : ''}
<div class="statRow"><span class="statLabel">类别</span><span class="statValue">${microCatName(it.cat)}</span></div>
@@ -4296,6 +4391,11 @@ function showBuildModal(b) {
<div class="cmdGrid">${acts || '<div style="color:#8899aa;font-size:12px;">暂无可交互动作</div>'}</div>
</div>
`;
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();
</script>
<script src="assets/game-api.js"></script>
<script src="assets/characters.js"></script>
<script>
// ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 =====
(async () => {