why_ga #1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': '宇森世界 - 沙盘与微观世界',
|
||||
'version': '0.2',
|
||||
'version': '0.3',
|
||||
'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)',
|
||||
'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。',
|
||||
'category': 'Game',
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 71 KiB |
@@ -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;
|
||||
|
After Width: | Height: | Size: 1014 B |
|
After Width: | Height: | Size: 958 B |
|
After Width: | Height: | Size: 932 B |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 147 KiB |
|
After Width: | Height: | Size: 861 B |
|
After Width: | Height: | Size: 853 B |
|
After Width: | Height: | Size: 828 B |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 1006 B |
|
After Width: | Height: | Size: 960 B |
|
After Width: | Height: | Size: 929 B |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 868 B |
|
After Width: | Height: | Size: 882 B |
|
After Width: | Height: | Size: 853 B |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 941 B |
|
After Width: | Height: | Size: 949 B |
|
After Width: | Height: | Size: 917 B |
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||