散落物系统 #35
@@ -27,6 +27,7 @@
|
||||
'views/npc_knowledge_views.xml',
|
||||
'views/npc_views.xml',
|
||||
'views/npc_menu.xml',
|
||||
'views/yt_world_chest_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import world_api
|
||||
from . import npc_api
|
||||
from . import chest_api
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json(data):
|
||||
headers = {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
}
|
||||
try:
|
||||
origin = request.httprequest.headers.get('Origin')
|
||||
if origin:
|
||||
headers['Access-Control-Allow-Origin'] = origin
|
||||
headers['Access-Control-Allow-Credentials'] = 'true'
|
||||
else:
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
except Exception:
|
||||
headers['Access-Control-Allow-Origin'] = '*'
|
||||
return request.make_response(
|
||||
__import__('json').dumps(data, ensure_ascii=False), headers=headers)
|
||||
|
||||
|
||||
def _post_body():
|
||||
try:
|
||||
raw = request.httprequest.get_data(as_text=True) or '{}'
|
||||
return __import__('json').loads(raw)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _require_user():
|
||||
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_world():
|
||||
user = request.env['res.users'].sudo().browse(request.session.uid) \
|
||||
if request.session.uid else request.env['res.users']
|
||||
if not user.exists():
|
||||
return None
|
||||
return request.env['yt.world'].sudo().get_or_create_for_user(user)
|
||||
|
||||
|
||||
class YtWorldChestApi(http.Controller):
|
||||
|
||||
# ── 列表:当前世界某地块的活跃宝箱 ──
|
||||
@http.route('/yt_world/api/chests', type='http', auth='public',
|
||||
methods=['GET', 'OPTIONS'], csrf=False)
|
||||
def list_chests(self, **kw):
|
||||
auth_err = _require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
world = _get_world()
|
||||
if not world:
|
||||
return _json({'ok': False, 'error': 'no_world'})
|
||||
domain = [('world_id', '=', world.id), ('state', '=', 'active')]
|
||||
q = kw.get('q'); r = kw.get('r')
|
||||
if q is not None and r is not None:
|
||||
# tile_ref 是 Json,按字典值过滤:保留该地块的宝箱
|
||||
recs = request.env['yt.world.chest'].sudo().search(domain)
|
||||
recs = recs.filtered(lambda c: (c.tile_ref or {}).get('q') == int(q)
|
||||
and (c.tile_ref or {}).get('r') == int(r))
|
||||
else:
|
||||
recs = request.env['yt.world.chest'].sudo().search(domain)
|
||||
out = [{
|
||||
'id': c.id,
|
||||
'cell_i': c.cell_i, 'cell_j': c.cell_j,
|
||||
'tier': c.tier, 'reward_type': c.reward_type,
|
||||
'reward_amount': c.reward_amount, 'state': c.state,
|
||||
'tile_ref': c.tile_ref or {},
|
||||
} for c in recs]
|
||||
return _json({'ok': True, 'chests': out})
|
||||
|
||||
# ── 生成:测试期前端每 10s 调一次;生产可挂 ir.cron ──
|
||||
@http.route('/yt_world/api/chest/generate', type='http', auth='public',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def generate_chest(self, **kw):
|
||||
auth_err = _require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
world = _get_world()
|
||||
if not world:
|
||||
return _json({'ok': False, 'error': 'no_world'})
|
||||
body = _post_body()
|
||||
q = body.get('q'); r = body.get('r')
|
||||
grid_n = int(body.get('grid_n') or 20)
|
||||
q = int(q) if q is not None else None
|
||||
r = int(r) if r is not None else None
|
||||
chest = request.env['yt.world.chest'].sudo().generate_for_world(
|
||||
world, q=q, r=r, grid_n=grid_n)
|
||||
if not chest:
|
||||
return _json({'ok': False, 'error': 'no_slot'})
|
||||
return _json({'ok': True, 'chest': {
|
||||
'id': chest.id, 'cell_i': chest.cell_i, 'cell_j': chest.cell_j,
|
||||
'tier': chest.tier, 'reward_type': chest.reward_type,
|
||||
'reward_amount': chest.reward_amount, 'state': chest.state,
|
||||
'tile_ref': chest.tile_ref or {},
|
||||
}})
|
||||
|
||||
# ── 开启:前端生物到达宝箱后调用 ──
|
||||
@http.route('/yt_world/api/chest/claim', type='http', auth='public',
|
||||
methods=['POST', 'OPTIONS'], csrf=False)
|
||||
def claim_chest(self, **kw):
|
||||
auth_err = _require_user()
|
||||
if auth_err:
|
||||
return _json(auth_err)
|
||||
world = _get_world()
|
||||
if not world:
|
||||
return _json({'ok': False, 'error': 'no_world'})
|
||||
body = _post_body()
|
||||
cid = body.get('chest_id')
|
||||
creature_cid = body.get('cid') or body.get('creature_cid')
|
||||
chest = request.env['yt.world.chest'].sudo().browse(int(cid) if cid else 0)
|
||||
if not chest.exists():
|
||||
return _json({'ok': False, 'error': 'no_chest'})
|
||||
if chest.world_id.id != world.id:
|
||||
return _json({'ok': False, 'error': 'forbidden'})
|
||||
res = chest.claim(creature_cid or '')
|
||||
return _json(res)
|
||||
@@ -128,6 +128,36 @@
|
||||
<field name="sequence">210</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 散落物(宝箱等地图随机掉落) ===== -->
|
||||
<record id="res_chest_wood" model="yt.codex.resource">
|
||||
<field name="code">chest_wood</field><field name="name">木宝箱</field><field name="icon">/assets/icons/scatter/chest_wood_front.png</field>
|
||||
<field name="category">scatter</field><field name="quality">common</field>
|
||||
<field name="source">微观世界随机生成</field><field name="uses">开启后获得少量铜币</field>
|
||||
<field name="description">最常见的粗糙木箱,由几块旧木板和锈铁箍钉成,里面通常装着零散的铜币。</field>
|
||||
<field name="sequence">10</field>
|
||||
</record>
|
||||
<record id="res_chest_silver" model="yt.codex.resource">
|
||||
<field name="code">chest_silver</field><field name="name">银宝箱</field><field name="icon">/assets/icons/scatter/chest_silver_front.png</field>
|
||||
<field name="category">scatter</field><field name="quality">rare</field>
|
||||
<field name="source">微观世界随机生成</field><field name="uses">开启后获得少量银币</field>
|
||||
<field name="description">银质镶边的精致宝箱,表面刻有朴素符文,开启时会掉落一些银币。</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
<record id="res_chest_gold" model="yt.codex.resource">
|
||||
<field name="code">chest_gold</field><field name="name">金宝箱</field><field name="icon">/assets/icons/scatter/chest_gold_front.png</field>
|
||||
<field name="category">scatter</field><field name="quality">epic</field>
|
||||
<field name="source">微观世界随机生成</field><field name="uses">开启后获得少量金币</field>
|
||||
<field name="description">沉甸甸的鎏金宝箱,边角镶嵌红宝石,里面藏着诱人的金币。</field>
|
||||
<field name="sequence">30</field>
|
||||
</record>
|
||||
<record id="res_chest_diamond" model="yt.codex.resource">
|
||||
<field name="code">chest_diamond</field><field name="name">钻石宝箱</field><field name="icon">/assets/icons/scatter/chest_diamond_front.png</field>
|
||||
<field name="category">scatter</field><field name="quality">legendary</field>
|
||||
<field name="source">微观世界随机生成</field><field name="uses">开启后获得少量钻石</field>
|
||||
<field name="description">传说中由星陨水晶打造的宝箱,周身泛着冷冽光辉,开启后可得珍贵钻石。</field>
|
||||
<field name="sequence">40</field>
|
||||
</record>
|
||||
|
||||
<!-- ===== 加工品 ===== -->
|
||||
<record id="res_plank" model="yt.codex.resource">
|
||||
<field name="code">plank</field><field name="name">木板</field><field name="icon">📦</field>
|
||||
|
||||
@@ -16,3 +16,4 @@ from . import npc
|
||||
from . import npc_relationship
|
||||
from . import npc_behavior
|
||||
from . import npc_knowledge
|
||||
from . import yt_world_chest
|
||||
|
||||
@@ -14,6 +14,7 @@ class YtCodexResource(models.Model):
|
||||
category = fields.Selection([
|
||||
('material', '材料'),
|
||||
('processed', '加工品'),
|
||||
('scatter', '散落物'),
|
||||
('special', '特殊'),
|
||||
], string='分类', required=True, default='material')
|
||||
quality = fields.Selection([
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import random
|
||||
from datetime import datetime
|
||||
from odoo import models, fields, api
|
||||
from odoo.tools import float_compare
|
||||
|
||||
# 默认微观沙盘网格尺寸(与前端 microBuildGeom 的 N 保持一致;
|
||||
# generate 时可由前端传 grid_n 覆盖,确保宝箱落在真实网格内)。
|
||||
DEFAULT_GRID_N = 20
|
||||
|
||||
# 品质 → 随机权重(普通多、传说少)
|
||||
TIER_WEIGHTS = [
|
||||
('common', 50),
|
||||
('rare', 30),
|
||||
('epic', 15),
|
||||
('legendary', 5),
|
||||
]
|
||||
|
||||
# 品质 → 奖励货币类型与数量区间 [min, max]
|
||||
TIER_REWARD = {
|
||||
'common': ('copper', 1, 10),
|
||||
'rare': ('silver', 1, 6),
|
||||
'epic': ('gold', 1, 5),
|
||||
'legendary': ('diamond', 1, 3),
|
||||
}
|
||||
|
||||
|
||||
class YtWorldChest(models.Model):
|
||||
_name = 'yt.world.chest'
|
||||
_description = '世界宝箱(微观沙盘随机生成,生物自动开启)'
|
||||
_order = 'create_time desc'
|
||||
|
||||
world_id = fields.Many2one('yt.world', string='世界', required=True,
|
||||
ondelete='cascade', index=True)
|
||||
tile_ref = fields.Json(string='所在地块', default=dict,
|
||||
help='{q, r} 归属哪个微观世界')
|
||||
cell_i = fields.Float(string='网格X', default=0,
|
||||
help='等距网格列(与建筑同级坐标系)')
|
||||
cell_j = fields.Float(string='网格Y', default=0,
|
||||
help='等距网格行(与建筑同级坐标系)')
|
||||
tier = fields.Selection([
|
||||
('common', '普通'),
|
||||
('rare', '稀有'),
|
||||
('epic', '史诗'),
|
||||
('legendary', '传说'),
|
||||
], string='品质', default='common', index=True)
|
||||
reward_type = fields.Selection([
|
||||
('copper', '铜币'),
|
||||
('silver', '银币'),
|
||||
('gold', '金币'),
|
||||
('diamond', '钻石'),
|
||||
], string='奖励类型', default='copper')
|
||||
reward_amount = fields.Integer(string='奖励数量', default=1)
|
||||
state = fields.Selection([
|
||||
('active', '可开启'),
|
||||
('claimed', '已开启'),
|
||||
('expired', '已过期'),
|
||||
], string='状态', default='active', index=True)
|
||||
claimed_by = fields.Char(string='开启者cid')
|
||||
create_time = fields.Datetime(string='生成时间', default=fields.Datetime.now)
|
||||
claimed_time = fields.Datetime(string='开启时间')
|
||||
|
||||
# ── 生成 ──
|
||||
@api.model
|
||||
def generate_for_world(self, world, q=None, r=None, grid_n=DEFAULT_GRID_N):
|
||||
"""在当前世界随机生成一个宝箱。
|
||||
|
||||
- 定位目标微观世界:优先用传入的 (q,r);否则从世界生物已有的 tile_ref 里随机挑一个;
|
||||
再不行就挑一个有微观世界的地块。
|
||||
- 排除建筑占用格,随机落在一个空格。
|
||||
- 按权重选品质,再派生奖励货币与数量。
|
||||
返回新建的 chest 记录;若无可落点返回 None。
|
||||
"""
|
||||
self = self.sudo()
|
||||
if not world:
|
||||
return None
|
||||
# 1) 选地块
|
||||
if q is None or r is None:
|
||||
q, r = self._pick_tile(world)
|
||||
if q is None or r is None:
|
||||
return None
|
||||
# 1.5) 限制同地块 active 宝箱数量(测试期 10s 一刷,避免满屏堆积)
|
||||
MAX_CHESTS_PER_TILE = 8
|
||||
existing = self.search([
|
||||
('world_id', '=', world.id),
|
||||
('state', '=', 'active'),
|
||||
('tile_ref', '=', {'q': q, 'r': r}),
|
||||
], order='create_time asc')
|
||||
if len(existing) >= MAX_CHESTS_PER_TILE:
|
||||
# 删掉最旧的,腾出一个空位给新宝箱
|
||||
to_remove = existing[:len(existing) - MAX_CHESTS_PER_TILE + 1]
|
||||
to_remove.unlink()
|
||||
# 2) 占用格集合(建筑 footprint)
|
||||
occ = self._occupied_cells(world, q, r, grid_n)
|
||||
# 3) 随机空格(留 1 格边距,防止贴边/溢出岛外)
|
||||
margin = 1
|
||||
free = []
|
||||
for i in range(margin, grid_n - margin):
|
||||
for j in range(margin, grid_n - margin):
|
||||
if (i, j) not in occ:
|
||||
free.append((i, j))
|
||||
if not free:
|
||||
return None
|
||||
ci, cj = random.choice(free)
|
||||
# 4) 品质 + 奖励
|
||||
tier = self._weighted_tier()
|
||||
rtype, rmin, rmax = TIER_REWARD[tier]
|
||||
amount = random.randint(rmin, rmax)
|
||||
chest = self.create({
|
||||
'world_id': world.id,
|
||||
'tile_ref': {'q': q, 'r': r},
|
||||
'cell_i': ci,
|
||||
'cell_j': cj,
|
||||
'tier': tier,
|
||||
'reward_type': rtype,
|
||||
'reward_amount': amount,
|
||||
})
|
||||
return chest
|
||||
|
||||
@api.model
|
||||
def cron_generate(self, world=None, per_run=1):
|
||||
"""供 ir.cron 批量生成。测试期可由前端每 10s 调 generate API 替代。"""
|
||||
if world is None:
|
||||
# 对所有有世界记录的用户世界各生成若干(避免跨世界串扰)
|
||||
worlds = self.env['yt.world'].sudo().search([])
|
||||
else:
|
||||
worlds = world
|
||||
for w in worlds:
|
||||
for _ in range(per_run):
|
||||
self.generate_for_world(w)
|
||||
|
||||
def _pick_tile(self, world):
|
||||
"""返回 (q, r)。优先挑有生物的微观世界,其次任意该世界地块,兜底 (0,0)。"""
|
||||
cr = self.env['yt.world.creature'].sudo().search(
|
||||
[('world_id', '=', world.id)], limit=200)
|
||||
tiles = set()
|
||||
for c in cr:
|
||||
tr = c.tile_ref or {}
|
||||
if tr.get('q') is not None and tr.get('r') is not None:
|
||||
tiles.add((int(tr['q']), int(tr['r'])))
|
||||
if tiles:
|
||||
return random.choice(list(tiles))
|
||||
tile = self.env['yt.world.tile'].sudo().search(
|
||||
[('world_id', '=', world.id)], limit=1)
|
||||
if tile:
|
||||
return (tile.q, tile.r)
|
||||
return (0, 0)
|
||||
|
||||
def _occupied_cells(self, world, q, r, grid_n):
|
||||
"""读取该地块微观世界建筑占用格集合(考虑旋转交换长宽)。"""
|
||||
occ = set()
|
||||
tile = self.env['yt.world.tile'].sudo().search([
|
||||
('world_id', '=', world.id), ('q', '=', q), ('r', '=', r),
|
||||
], limit=1)
|
||||
mw = tile and tile.micro_world_id
|
||||
if not mw:
|
||||
return occ
|
||||
for b in (mw.build_items or []):
|
||||
t = b.get('type')
|
||||
bi = b.get('i', 0)
|
||||
bj = b.get('j', 0)
|
||||
ew = b.get('w') or 1
|
||||
eh = b.get('h') or 1
|
||||
if ((b.get('rot') or 0) % 180) != 0:
|
||||
ew, eh = eh, ew
|
||||
for di in range(int(ew)):
|
||||
for dj in range(int(eh)):
|
||||
occ.add((bi + di, bj + dj))
|
||||
return occ
|
||||
|
||||
def _weighted_tier(self):
|
||||
total = sum(w for _, w in TIER_WEIGHTS)
|
||||
x = random.uniform(0, total)
|
||||
for tier, w in TIER_WEIGHTS:
|
||||
x -= w
|
||||
if x <= 0:
|
||||
return tier
|
||||
return 'common'
|
||||
|
||||
# ── 开启 ──
|
||||
def claim(self, cid):
|
||||
"""由某生物(cid)开启当前宝箱,发放奖励给该世界角色。
|
||||
|
||||
返回 dict:{ok, reward_type, amount, balance} 或 {ok:False, error}。
|
||||
幂等:已 claimed 的宝箱再次调用返回原有结果(不重复发奖)。
|
||||
"""
|
||||
self.ensure_one()
|
||||
self = self.sudo()
|
||||
if self.state == 'claimed':
|
||||
return {'ok': True, 'already': True, 'reward_type': self.reward_type,
|
||||
'amount': self.reward_amount,
|
||||
'balance': self._char_balance(self.reward_type)}
|
||||
if self.state != 'active':
|
||||
return {'ok': False, 'error': 'not_active'}
|
||||
char = self.env['yt.world.character'].sudo().search(
|
||||
[('world_id', '=', self.world_id.id)], limit=1)
|
||||
if not char:
|
||||
return {'ok': False, 'error': 'no_char'}
|
||||
rtype = self.reward_type
|
||||
cur = getattr(char, rtype) or 0
|
||||
char.write({rtype: cur + self.reward_amount})
|
||||
self.write({
|
||||
'state': 'claimed',
|
||||
'claimed_by': cid,
|
||||
'claimed_time': fields.Datetime.now(),
|
||||
})
|
||||
return {'ok': True, 'reward_type': rtype, 'amount': self.reward_amount,
|
||||
'balance': getattr(char, rtype) or 0}
|
||||
|
||||
def _char_balance(self, rtype):
|
||||
char = self.env['yt.world.character'].sudo().search(
|
||||
[('world_id', '=', self.world_id.id)], limit=1)
|
||||
return getattr(char, rtype) or 0 if char else 0
|
||||
@@ -59,3 +59,6 @@ access_yt_world_stock_portal,yt.world.stock portal,model_yt_world_stock,base.gro
|
||||
access_yt_world_task_user,yt.world.task user,model_yt_world_task,base.group_user,1,1,1,1
|
||||
access_yt_world_task_public,yt.world.task public,model_yt_world_task,base.group_public,1,0,0,0
|
||||
access_yt_world_task_portal,yt.world.task portal,model_yt_world_task,base.group_portal,1,0,0,0
|
||||
access_yt_world_chest_user,yt.world.chest user,model_yt_world_chest,base.group_user,1,1,1,1
|
||||
access_yt_world_chest_public,yt.world.chest public,model_yt_world_chest,base.group_public,1,0,0,0
|
||||
access_yt_world_chest_portal,yt.world.chest portal,model_yt_world_chest,base.group_portal,1,0,0,0
|
||||
|
||||
|
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_yt_world_chest_tree" model="ir.ui.view">
|
||||
<field name="name">yt.world.chest.tree</field>
|
||||
<field name="model">yt.world.chest</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="id"/>
|
||||
<field name="tile_ref" string="地块"/>
|
||||
<field name="cell_i"/>
|
||||
<field name="cell_j"/>
|
||||
<field name="tier"/>
|
||||
<field name="reward_type"/>
|
||||
<field name="reward_amount"/>
|
||||
<field name="state"/>
|
||||
<field name="claimed_by"/>
|
||||
<field name="create_time"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_yt_world_chest_form" model="ir.ui.view">
|
||||
<field name="name">yt.world.chest.form</field>
|
||||
<field name="model">yt.world.chest</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<group>
|
||||
<field name="world_id"/>
|
||||
<field name="tile_ref"/>
|
||||
<field name="cell_i"/>
|
||||
<field name="cell_j"/>
|
||||
<field name="tier"/>
|
||||
<field name="reward_type"/>
|
||||
<field name="reward_amount"/>
|
||||
<field name="state"/>
|
||||
<field name="claimed_by"/>
|
||||
<field name="create_time"/>
|
||||
<field name="claimed_time"/>
|
||||
</group>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_yt_world_chest" model="ir.actions.act_window">
|
||||
<field name="name">世界宝箱</field>
|
||||
<field name="res_model">yt.world.chest</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_yt_world_chest" name="世界宝箱"
|
||||
parent="menu_yt_world_root" action="action_yt_world_chest" sequence="90"/>
|
||||
</odoo>
|
||||
@@ -493,8 +493,17 @@ function updateChars(dt) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 键盘 WASD 控制(仅选中角色):8 方向实时移动,优先级高于点击移动/漫游
|
||||
const kbdDir = ch.selected ? wasdDirection() : null;
|
||||
// 宝箱目标失效(被其他角色开启或切地块清空):重置为 idle
|
||||
if (ch._chestTarget && (!window.microChests || !window.microChests.includes(ch._chestTarget))) {
|
||||
ch._chestTarget = null;
|
||||
if (ch.state === 'walk') {
|
||||
ch.state = 'idle'; ch.frame = 0; ch.frameT = 0;
|
||||
ch.wanderT = 0; ch._userMove = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 玩家移动控制已移除(含 WASD 键盘):kbdDir 恒为 null,生物只由 AI/漫游/宝箱逻辑驱动
|
||||
const kbdDir = null;
|
||||
if (kbdDir) {
|
||||
// 键盘移动恒用种族基础移速(不受漫游/点击移动改过的 ch.speed 残留影响)
|
||||
const kbdSpeed = raceStats(ch.race).speed;
|
||||
@@ -522,32 +531,61 @@ function updateChars(dt) {
|
||||
// 原有漫游 / 行走逻辑
|
||||
// 自由移动开关:ch.freeMove !== false 才允许自主漫游(默认开,可被面板按钮关闭)
|
||||
if (ch.freeMove !== false && !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 = [];
|
||||
}
|
||||
// 宝箱吸引:附近存在活跃宝箱时优先去开启(测试阶段吸引半径全屏)
|
||||
const chests = window.microChests;
|
||||
const c2s = window.microCellToScreen;
|
||||
let best = null, bestD = Infinity;
|
||||
if (chests && c2s && chests.length) {
|
||||
for (const chest of chests) {
|
||||
if (chest._claiming) continue;
|
||||
const sp = c2s(chest.cell_i + 0.5, chest.cell_j + 0.5);
|
||||
const d = Math.hypot(sp.x - ch.x, sp.y - ch.y);
|
||||
if (d < bestD) { bestD = d; best = { chest, sp }; }
|
||||
}
|
||||
}
|
||||
if (best && bestD < 2000) { // 吸引半径:2000px 已覆盖整个浮岛视图
|
||||
ch._chestTarget = best.chest;
|
||||
ch.targetX = best.sp.x;
|
||||
ch.targetY = best.sp.y;
|
||||
ch.state = 'walk';
|
||||
ch.frame = 0; ch.frameT = 0;
|
||||
ch.speed = 90 + Math.random() * 30;
|
||||
ch._userMove = false;
|
||||
ch.wanderT = 0;
|
||||
ch.trail = [];
|
||||
} else {
|
||||
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) {
|
||||
if (dist < 3 || (ch._chestTarget && dist < 35)) {
|
||||
// 宝箱目标到达:调用 world.html 的开启逻辑(飘字+发奖+移除宝箱)
|
||||
if (ch._chestTarget && window.microClaimChest) {
|
||||
window.microClaimChest(ch._chestTarget, ch);
|
||||
}
|
||||
ch._chestTarget = null;
|
||||
ch.state = 'idle';
|
||||
ch.frame = 0;
|
||||
ch.frameT = 0;
|
||||
@@ -877,19 +915,9 @@ function hitChar(wx, wy) {
|
||||
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;
|
||||
}
|
||||
// no-op: boundary restriction removed; characters can roam freely within the micro canvas
|
||||
}
|
||||
|
||||
// ---- UI 面板 ----
|
||||
|
||||
@@ -446,4 +446,48 @@ const API = {
|
||||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||||
}
|
||||
},
|
||||
|
||||
// ---------- 宝箱系统 ----------
|
||||
async chests(q, r) {
|
||||
try {
|
||||
const qs = (q !== undefined && r !== undefined) ? ('&q=' + q + '&r=' + r) : '';
|
||||
const r0 = await fetch(_worldUrl('/yt_world/api/chests') + qs, { credentials: _wcred });
|
||||
if (!r0.ok) return { ok: false, error: 'http_' + r0.status };
|
||||
return await r0.json();
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||||
}
|
||||
},
|
||||
async chestGenerate(q, r, grid_n = 20) {
|
||||
try {
|
||||
const r0 = await fetch(_worldUrl('/yt_world/api/chest/generate'), {
|
||||
method: 'POST', credentials: _wcred,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ q, r, grid_n: grid_n }),
|
||||
});
|
||||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||||
return { ok: false, error: 'auth' };
|
||||
}
|
||||
const j = await r0.json();
|
||||
return j && j.ok === false ? j : (j || { ok: true });
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||||
}
|
||||
},
|
||||
async chestClaim(chest_id, cid) {
|
||||
try {
|
||||
const r0 = await fetch(_worldUrl('/yt_world/api/chest/claim'), {
|
||||
method: 'POST', credentials: _wcred,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chest_id, cid }),
|
||||
});
|
||||
if (r0.redirected || (r0.url && r0.url.indexOf('/web/login') >= 0)) {
|
||||
return { ok: false, error: 'auth' };
|
||||
}
|
||||
const j = await r0.json();
|
||||
return j && j.ok === false ? j : (j || { ok: true });
|
||||
} catch (e) {
|
||||
return { ok: false, error: 'network', detail: String(e && e.message || e) };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 853 KiB |
|
After Width: | Height: | Size: 945 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 840 KiB |
|
After Width: | Height: | Size: 622 KiB |
|
After Width: | Height: | Size: 1002 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1019 KiB |
|
After Width: | Height: | Size: 1014 KiB |
|
After Width: | Height: | Size: 891 KiB |
|
After Width: | Height: | Size: 706 KiB |
|
After Width: | Height: | Size: 700 KiB |
|
After Width: | Height: | Size: 805 KiB |
|
After Width: | Height: | Size: 750 KiB |
|
After Width: | Height: | Size: 556 KiB |
|
After Width: | Height: | Size: 854 KiB |
|
After Width: | Height: | Size: 745 KiB |
|
After Width: | Height: | Size: 467 KiB |
@@ -55,7 +55,6 @@ document.querySelectorAll('.world-nav button[data-panel]').forEach(b => {
|
||||
// ---------- 程序化矢量缩略图(零 AI 生图,不消耗积分) ----------
|
||||
function hashStr(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return h>>>0;}
|
||||
function rng(seed){let s=seed>>>0;return function(){s=(Math.imul(s,1664525)+1013904223)>>>0;return s/4294967296;};}
|
||||
const TIERCOL={'普通':'#9aa6c4','精良':'#5fd38a','稀有':'#4cc9f0','史诗':'#c77dff','传说':'#e7b85c'};
|
||||
function starPts(cx,cy,spikes,outer,inner){let pts=[],rot=-Math.PI/2;for(let i=0;i<spikes*2;i++){const rad=i%2?inner:outer;const a=rot+i*Math.PI/spikes;pts.push((cx+Math.cos(a)*rad).toFixed(1)+','+(cy+Math.sin(a)*rad).toFixed(1));}return pts.join(' ');}
|
||||
function polygonPts(cx,cy,sides,R){let pts=[];for(let i=0;i<sides;i++){const a=-Math.PI/2+i*2*Math.PI/sides;pts.push((cx+Math.cos(a)*R).toFixed(1)+','+(cy+Math.sin(a)*R).toFixed(1));}return pts.join(' ');}
|
||||
function shRace(r,s){const v=Math.floor(r()*3);let x=`<ellipse cx="50" cy="55" rx="19" ry="21" fill="url(#fg${s})" stroke="rgba(255,255,255,.25)" stroke-width="1"/>`;if(v===0){x+=`<path d="M35 40 L31 20 L43 36 Z" fill="url(#fg${s})"/><path d="M65 40 L69 20 L57 36 Z" fill="url(#fg${s})"/>`;}else if(v===1){x+=`<path d="M32 52 L20 42 L36 46 Z" fill="url(#fg${s})"/><path d="M68 52 L80 42 L64 46 Z" fill="url(#fg${s})"/>`;}else{x+=`<path d="M34 36 L42 24 L50 34 L58 24 L66 36 Z" fill="url(#fg${s})"/>`;}x+=`<circle cx="43" cy="55" r="2.6" fill="#0b1020"/><circle cx="57" cy="55" r="2.6" fill="#0b1020"/>`;return x;}
|
||||
|
||||
@@ -153,10 +153,11 @@ body::before{
|
||||
.card.race .tags{display:flex;gap:4px;flex-wrap:wrap;justify-content:center;margin-top:2px;}
|
||||
.card.race .tag{font-size:9px;padding:1px 6px;border-radius:4px;background:rgba(255,255,255,.06);color:#8899aa;}
|
||||
|
||||
/* 装备/天赋/技能书等通用标签 */
|
||||
.card.equip .tags,.card.talent .tags,.card.skill_book .tags{display:flex;gap:4px;flex-wrap:wrap;justify-content:center;margin-top:2px;}
|
||||
.card.equip .tag,.card.talent .tag,.card.skill_book .tag{font-size:9px;padding:1px 6px;border-radius:4px;background:rgba(255,255,255,.06);color:#8899aa;}
|
||||
/* 装备/天赋/技能书/散落物等通用标签 */
|
||||
.card.equip .tags,.card.talent .tags,.card.skill_book .tags,.card.scatter .tags{display:flex;gap:4px;flex-wrap:wrap;justify-content:center;margin-top:2px;}
|
||||
.card.equip .tag,.card.talent .tag,.card.skill_book .tag,.card.scatter .tag{font-size:9px;padding:1px 6px;border-radius:4px;background:rgba(255,255,255,.06);color:#8899aa;}
|
||||
.card.equip{border-color:rgba(120,200,255,.12);}
|
||||
.card.scatter{border-color:rgba(245,158,11,.12);}
|
||||
|
||||
/* 野怪等级角标 */
|
||||
.card .tierbadge{position:absolute;top:8px;right:8px;font-size:9px;font-weight:700;padding:1px 6px;border-radius:6px;letter-spacing:.5px;}
|
||||
@@ -284,6 +285,7 @@ body::before{
|
||||
<span class="rule-tab" data-cat="skill_book" onclick="setCat('skill_book')">📖 技能书</span>
|
||||
<span class="rule-tab" data-cat="book" onclick="setCat('book')">📚 书籍</span>
|
||||
<span class="rule-tab" data-cat="equip" onclick="setCat('equip')">🛡️ 装备</span>
|
||||
<span class="rule-tab" data-cat="scatter" onclick="setCat('scatter')">🎁 散落物</span>
|
||||
<span class="rule-tab" data-cat="special" onclick="setCat('special')">✨ 特殊</span>
|
||||
</div>
|
||||
<div class="filterRow">
|
||||
@@ -339,7 +341,7 @@ body::before{
|
||||
const QUALITIES = ['common','uncommon','rare','epic','legendary','mythic'];
|
||||
const QUALITY_NAMES = { common:'普通', uncommon:'优秀', rare:'精良', epic:'史诗', legendary:'传说', mythic:'神话' };
|
||||
const TIER_NAMES = { normal:'普通野怪', elite:'精英野怪', boss:'Boss' };
|
||||
const CAT_ORDER = ['material','processed','creature','monster','race','talent','skill_book','book','equip','special'];
|
||||
const CAT_ORDER = ['material','processed','creature','monster','race','talent','skill_book','book','equip','scatter','special'];
|
||||
const SLOT_NAMES = { weapon:'武器', armor:'护甲', accessory:'饰品', boots:'鞋子' };
|
||||
|
||||
// 当前玩家已拥有天赋(从后端加载){ code: { level, active } }
|
||||
@@ -693,7 +695,7 @@ function showDetail(e){
|
||||
const c = e.qual==='common'?'156,163,175':e.qual==='uncommon'?'34,197,94':e.qual==='rare'?'59,130,246':e.qual==='epic'?'168,85,247':e.qual==='legendary'?'245,158,11':'220,38,38';
|
||||
const t = e.qual==='common'?'#9ca3af':e.qual==='uncommon'?'#4ade80':e.qual==='rare'?'#60a5fa':e.qual==='epic'?'#c084fc':e.qual==='legendary'?'#fbbf24':'#f87171';
|
||||
html += `<span class="mt" style="background:rgba(${c},.15);color:${t}">${QUALITY_NAMES[e.qual]}</span>`;
|
||||
const catLabel = e.type==='talent'?'🌟 天赋':e.type==='skill_book'?'📖 技能书':e.type==='equip'?'🛡️ 装备':e.cat==='material'?'材料':e.cat==='processed'?'加工品':'特殊';
|
||||
const catLabel = e.type==='talent'?'🌟 天赋':e.type==='skill_book'?'📖 技能书':e.type==='equip'?'🛡️ 装备':e.cat==='material'?'材料':e.cat==='processed'?'加工品':e.cat==='scatter'?'🎁 散落物':'特殊';
|
||||
html += `<span class="mt">${catLabel}</span>`;
|
||||
} else if(e.type==='creature'){
|
||||
html += `<span class="mt">🐾 生物</span><span class="mt">${e.hab||''}</span><span class="mt">${e.diet||''}</span><span class="mt">${e.tame?'✅ 可驯养':'❌ 野性'}</span>`;
|
||||
|
||||
@@ -1519,6 +1519,10 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
||||
<span class="top-icon-label">成就</span>
|
||||
<span class="top-icon-badge" id="achBadge" hidden></span>
|
||||
</button>
|
||||
<button class="top-icon" id="btnWebsite" type="button" title="官网">
|
||||
<img src="assets/icons/website/website.png?v=2" alt="官网">
|
||||
<span class="top-icon-label">官网</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主沙盘底部功能框架(世界等级经验条置于最右侧) -->
|
||||
@@ -1540,10 +1544,6 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
|
||||
<img src="assets/icons/height/height.png?v=2" alt="高度">
|
||||
<span class="qi-label">高度</span>
|
||||
</button>
|
||||
<button class="quick-icon" id="btnWebsite" type="button" title="官网">
|
||||
<img src="assets/icons/website/website.png?v=2" alt="官网">
|
||||
<span class="qi-label">官网</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="bb-slots" style="display:none;">
|
||||
<button class="bb-btn" id="btnManage" type="button" style="display:none;">⚙️ 管理</button>
|
||||
@@ -2464,6 +2464,15 @@ let microPanStart = null; // 平移起点 {x,y}
|
||||
let microViewOpen = false; // 微观视图是否打开:打开时主世界 render 暂停重绘,避免双循环抢 CPU
|
||||
let microHoverB = null; // 鼠标当前悬停的建筑(已过滤 road/ground);非空时在渲染层额外绘高亮 footprint
|
||||
|
||||
// 宝箱系统:测试期每 10s 在微观沙盘随机生成,地块生物自动前往开启
|
||||
let microChests = []; // 当前地块活跃宝箱 [{id,cell_i,cell_j,tier,reward_type,reward_amount,_claiming}]
|
||||
let microFloatTexts = []; // 开启飘字 [{x,y,text,color,t,life}]
|
||||
let microChestGenTimer = null; // 测试期生成定时器(10s)
|
||||
const MICRO_CHEST_GEN_MS = 60000; // 固定 1 分钟生成一个
|
||||
const MICRO_CHEST_ATTRACT_R = 1e9; // 吸引半径(全浮岛,任意散点生物都能感知最近宝箱)
|
||||
const MICRO_CHEST_CLAIM_R = 30; // 触发开启半径(画布像素)
|
||||
const MICRO_CHEST_SPEED = 150; // 生物走向宝箱速度(画布像素/秒)
|
||||
|
||||
// 建造面板展开时:世界整体上移 + 等比缩小,腾出底部给面板(仅改变换,不缩放画布本身 → 无闪烁)
|
||||
let microBuildOpenT = 0; // 0=收起 1=展开(每帧缓动逼近)
|
||||
let microBuildScale = 1; // 当前世界缩放系数(建造展开时 <1)
|
||||
@@ -2746,6 +2755,150 @@ async function microLoadGame() {
|
||||
} catch (e) { /* 离线兜底:不阻塞微观世界 */ }
|
||||
}
|
||||
|
||||
// ============ 宝箱系统:生成 / 加载 / 开启 / 表现 ============
|
||||
// 宝箱用网格坐标 (cell_i,cell_j),与建筑同一等距坐标系;后端生成时已排除建筑占用格,天然不重叠。
|
||||
async function microLoadChests(q, r) {
|
||||
if (q == null || r == null) return;
|
||||
try {
|
||||
const j = await API.chests(q, r);
|
||||
if (j && j.ok && Array.isArray(j.chests)) {
|
||||
microChests = j.chests.map(c => ({ ...c, _claiming: false }));
|
||||
window.microChests = microChests; // 同步给 characters.js(否则其读到的是旧空数组引用)
|
||||
}
|
||||
} catch (e) { /* 离线兜底 */ }
|
||||
}
|
||||
|
||||
async function microGenerateChest() {
|
||||
if (!microState || !microState.tile) return;
|
||||
const t = microState.tile;
|
||||
const gridN = (microGridDims().cols) || 20;
|
||||
try {
|
||||
const j = await API.chestGenerate(t.q, t.r, gridN);
|
||||
if (j && j.ok && j.chest) microChests.push({ ...j.chest, _claiming: false });
|
||||
} catch (e) { /* 离线兜底 */ }
|
||||
}
|
||||
|
||||
function microStartChestGen() {
|
||||
if (microChestGenTimer) return;
|
||||
microChestGenTimer = setInterval(microGenerateChest, MICRO_CHEST_GEN_MS);
|
||||
}
|
||||
function microStopChestGen() {
|
||||
if (microChestGenTimer) { clearInterval(microChestGenTimer); microChestGenTimer = null; }
|
||||
microChests = [];
|
||||
window.microChests = microChests; // 同步给 characters.js
|
||||
microFloatTexts = [];
|
||||
}
|
||||
|
||||
// 生物到达宝箱后调用:乐观飘字 + 后端发奖 + 更新货币 HUD
|
||||
async function microClaimChest(ch, u) {
|
||||
if (ch._claiming) return;
|
||||
ch._claiming = true;
|
||||
const sp = microCellToScreen(ch.cell_i + 0.5, ch.cell_j + 0.5);
|
||||
microFloatTexts.push({
|
||||
x: sp.x, y: sp.y - 42,
|
||||
text: '+' + ch.reward_amount + microChestIcon(ch.reward_type),
|
||||
color: microChestColor(ch.reward_type), t: 0, life: 1.2,
|
||||
});
|
||||
const idx = microChests.indexOf(ch);
|
||||
if (idx >= 0) microChests.splice(idx, 1); // 立即移除:其他生物失去目标、回原位
|
||||
try {
|
||||
const j = await API.chestClaim(ch.id, u.id || '');
|
||||
if (j && j.ok && j.balance != null && microChar) {
|
||||
microChar[ch.reward_type] = j.balance;
|
||||
microRenderCharHud();
|
||||
}
|
||||
} catch (e) { /* 离线兜底 */ }
|
||||
}
|
||||
|
||||
// 暴露给外部角色系统(characters.js)以驱动精灵图生物抢宝箱
|
||||
window.microChests = microChests;
|
||||
window.microCellToScreen = microCellToScreen;
|
||||
window.microClaimChest = microClaimChest;
|
||||
|
||||
function microChestIcon(t) {
|
||||
return ({ copper: '🥉', silver: '🥈', gold: '🪙', diamond: '💎' })[t] || '🪙';
|
||||
}
|
||||
function microChestColor(t) {
|
||||
return ({ copper: '#c8854a', silver: '#cfd6e0', gold: '#ffd23f', diamond: '#7ad7ff' })[t] || '#ffd23f';
|
||||
}
|
||||
function microChestGlow(tier) {
|
||||
return ({ common: '#9aa4b2', rare: '#4aa3ff', epic: '#b46bff', legendary: '#ffb13f' })[tier] || '#9aa4b2';
|
||||
}
|
||||
function microChestEmoji(tier) {
|
||||
return ({ common: '📦', rare: '🎁', epic: '🧰', legendary: '💠' })[tier] || '📦';
|
||||
}
|
||||
|
||||
// 宝箱图片映射(正视图用于图鉴/面板,俯视图用于微观世界地图)
|
||||
const MICRO_CHEST_TOP_IMAGES = {
|
||||
common: 'assets/icons/scatter/chest_wood_top.png?v=20260911',
|
||||
rare: 'assets/icons/scatter/chest_silver_top.png?v=20260911',
|
||||
epic: 'assets/icons/scatter/chest_gold_top.png?v=20260911',
|
||||
legendary: 'assets/icons/scatter/chest_diamond_top.png?v=20260911',
|
||||
};
|
||||
const MICRO_CHEST_ICON_IMAGES = {
|
||||
common: 'assets/icons/scatter/chest_wood_front.png?v=20260911',
|
||||
rare: 'assets/icons/scatter/chest_silver_front.png?v=20260911',
|
||||
epic: 'assets/icons/scatter/chest_gold_front.png?v=20260911',
|
||||
legendary: 'assets/icons/scatter/chest_diamond_front.png?v=20260911',
|
||||
};
|
||||
const _chestImageCache = {};
|
||||
function getChestImage(url) {
|
||||
if (!url) return null;
|
||||
if (_chestImageCache[url]) return _chestImageCache[url];
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
_chestImageCache[url] = img;
|
||||
return img;
|
||||
}
|
||||
function microChestTopImage(tier) { return MICRO_CHEST_TOP_IMAGES[tier] || null; }
|
||||
function microChestIconImage(tier) { return MICRO_CHEST_ICON_IMAGES[tier] || null; }
|
||||
|
||||
// 在网格层之上绘制所有活跃宝箱(呼吸光晕 + 漂浮主体/俯视图)
|
||||
function microDrawChests(c) {
|
||||
if (!microChests.length) return;
|
||||
const t = microState.animTime;
|
||||
microChests.forEach(ch => {
|
||||
const sp = microCellToScreen(ch.cell_i + 0.5, ch.cell_j + 0.5);
|
||||
const glow = microChestGlow(ch.tier);
|
||||
// 主体:优先使用俯视图 PNG,未加载成功则回退 emoji
|
||||
const imgUrl = microChestTopImage(ch.tier);
|
||||
const img = imgUrl ? getChestImage(imgUrl) : null;
|
||||
if (img && img.complete && img.naturalWidth) {
|
||||
const baseW = 42, baseH = 42;
|
||||
c.save();
|
||||
c.translate(sp.x, sp.y - 14);
|
||||
// 45度俯视:轻微压扁并旋转,贴合等距地面
|
||||
c.scale(1, 0.72);
|
||||
c.drawImage(img, -baseW / 2, -baseH / 2, baseW, baseH);
|
||||
c.restore();
|
||||
} else {
|
||||
c.save();
|
||||
c.translate(sp.x, sp.y - 14);
|
||||
c.font = '30px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
c.fillText(microChestEmoji(ch.tier), 0, 0);
|
||||
c.restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 开启飘字:上飘 + 淡出
|
||||
function microDrawFloatTexts(c, dt) {
|
||||
for (let i = microFloatTexts.length - 1; i >= 0; i--) {
|
||||
const f = microFloatTexts[i];
|
||||
f.t += dt;
|
||||
const p = f.t / f.life;
|
||||
if (p >= 1) { microFloatTexts.splice(i, 1); continue; }
|
||||
c.save();
|
||||
c.globalAlpha = 1 - p;
|
||||
c.font = 'bold 18px sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||
c.lineWidth = 3; c.strokeStyle = 'rgba(0,0,0,0.6)';
|
||||
const y = f.y - p * 38;
|
||||
c.strokeText(f.text, f.x, y);
|
||||
c.fillStyle = f.color; c.fillText(f.text, f.x, y);
|
||||
c.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 玩家生物持久化恢复(生物属于玩家账号,存于 yt.world.creature,按 tile_ref 归属地块) =====
|
||||
// 服务端生物记录 → 地块居民 unit 对象
|
||||
function microMakeUnitFromCreature(x) {
|
||||
@@ -4256,6 +4409,8 @@ function enterMicroWorld(tile) {
|
||||
microLoadIcons(); // 进入微观视图即预加载建造贴图,避免已放置建筑先显示文字
|
||||
microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效
|
||||
microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态
|
||||
microLoadChests(tile.q, tile.r); // 拉取本地块已生成的宝箱
|
||||
microStartChestGen(); // 启动测试期 10s 生成定时器
|
||||
fetchTasks(); // 拉取任务定义 + 角色进度(新手主线)
|
||||
// 资源系统:登录时一次性结算离线产出并兜底回库,再启动在线自动进账
|
||||
microSettle(true, true).then(() => microRefreshResources());
|
||||
@@ -4705,6 +4860,8 @@ function startMicroRender() {
|
||||
const padX = Rx * 0.12, padY = Ry * 0.12, minD = Math.min(Rx, Ry) * 0.24;
|
||||
const roadSet = microRoadKeySet();
|
||||
const onRoad = roadSet.size > 0;
|
||||
// 宝箱层(建筑之上、居民之下)
|
||||
microDrawChests(microCtx);
|
||||
if (!hasBgImage) {
|
||||
microState.units.forEach((u, k) => {
|
||||
// 由建筑层负责渲染的种族角色不再在这里画 emoji,避免重影
|
||||
@@ -4723,14 +4880,58 @@ function startMicroRender() {
|
||||
px = a.x + (b.x - a.x) * e; py = a.y + (b.y - a.y) * e;
|
||||
const vx = b.x - a.x; if (Math.abs(vx) > 0.5) face = vx > 0 ? 1 : -1;
|
||||
moving = (p.ci !== p.nci) || (p.cj !== p.ncj);
|
||||
// 道路巡逻生物同样会被宝箱吸引:有宝箱时离开道路直奔宝箱,没有时继续巡逻
|
||||
let tChest = null, tDist = Infinity;
|
||||
for (const ch of microChests) {
|
||||
const sp = microCellToScreen(ch.cell_i + 0.5, ch.cell_j + 0.5);
|
||||
const d = Math.hypot(sp.x - px, sp.y - py);
|
||||
if (d < tDist) { tDist = d; tChest = { x: sp.x, y: sp.y, ch }; }
|
||||
}
|
||||
if (tChest && tDist < MICRO_CHEST_ATTRACT_R) {
|
||||
if (u._seekPx == null) { u._seekPx = px; u._seekPy = py; }
|
||||
const dx = tChest.x - u._seekPx, dy = tChest.y - u._seekPy, dist = Math.hypot(dx, dy);
|
||||
if (dist > MICRO_CHEST_CLAIM_R) {
|
||||
const step = Math.min(dist, MICRO_CHEST_SPEED * dt);
|
||||
u._seekPx += dx / dist * step; u._seekPy += dy / dist * step;
|
||||
px = u._seekPx; py = u._seekPy; moving = true;
|
||||
if (Math.abs(dx) > 0.5) face = dx > 0 ? 1 : -1;
|
||||
} else {
|
||||
microClaimChest(tChest.ch, u);
|
||||
}
|
||||
} else {
|
||||
u._seekPx = null; u._seekPy = null;
|
||||
}
|
||||
} else {
|
||||
const h = microHash(u.id);
|
||||
const rx1 = ((h >> 2) % 100) / 100;
|
||||
const ry1 = ((h >> 9) % 100) / 100;
|
||||
px = cx + (rx1 * 2 - 1) * (Rx - padX);
|
||||
py = cy + (ry1 * 2 - 1) * (Ry - padY);
|
||||
const dx_ = px - cx, dy_ = py - cy, d = Math.hypot(dx_, dy_);
|
||||
if (d < minD && d > 0.001) { const kk = minD / d; px = cx + dx_ * kk; py = cy + dy_ * kk; }
|
||||
const homeX = cx + (rx1 * 2 - 1) * (Rx - padX);
|
||||
const homeY = cy + (ry1 * 2 - 1) * (Ry - padY);
|
||||
const dxh = homeX - cx, dyh = homeY - cy, dh = Math.hypot(dxh, dyh);
|
||||
const hx = (dh < minD && dh > 0.001) ? cx + dxh * (minD / dh) : homeX;
|
||||
const hy = (dh < minD && dh > 0.001) ? cy + dyh * (minD / dh) : homeY;
|
||||
if (u._px == null) { u._px = hx; u._py = hy; }
|
||||
// 宝箱吸引:所有散点生物寻找最近活跃宝箱并前往开启
|
||||
let tgt = null, bd = Infinity;
|
||||
for (const ch of microChests) {
|
||||
const sp = microCellToScreen(ch.cell_i + 0.5, ch.cell_j + 0.5);
|
||||
const d = Math.hypot(sp.x - u._px, sp.y - u._py);
|
||||
if (d < bd) { bd = d; tgt = { x: sp.x, y: sp.y, ch }; }
|
||||
}
|
||||
if (tgt && bd < MICRO_CHEST_ATTRACT_R) {
|
||||
const dx = tgt.x - u._px, dy = tgt.y - u._py, dist = Math.hypot(dx, dy);
|
||||
if (dist > MICRO_CHEST_CLAIM_R) {
|
||||
const step = Math.min(dist, MICRO_CHEST_SPEED * dt);
|
||||
u._px += dx / dist * step; u._py += dy / dist * step;
|
||||
moving = true;
|
||||
if (Math.abs(dx) > 0.5) face = dx > 0 ? 1 : -1;
|
||||
} else {
|
||||
microClaimChest(tgt.ch, u); // 到达 → 开启(内部移除宝箱,生物随后回原位)
|
||||
}
|
||||
} else {
|
||||
u._px += (hx - u._px) * 0.04; u._py += (hy - u._py) * 0.04;
|
||||
}
|
||||
px = u._px; py = u._py;
|
||||
}
|
||||
// 行走 bob(缓动上下浮动)+ 呼吸脉冲(停步时)
|
||||
const bob = moving ? Math.abs(Math.sin(microState.animTime * 6 + k)) * (Math.min(Rx, Ry) * 0.03) : 0;
|
||||
@@ -4764,6 +4965,8 @@ function startMicroRender() {
|
||||
// 大气粒子层(背景图模式跳过,保持纯净背景图)
|
||||
if (!hasBgImage) drawMicroAtmoParticles(microCtx, mW, mH, terrain);
|
||||
|
||||
// 开启飘字(仍在变换内,跟随缩放)
|
||||
microDrawFloatTexts(microCtx, dt);
|
||||
// ---- 结束变换:恢复画布坐标系,绘制 UI 层不受 zoom 影响 ----
|
||||
microCtx.restore();
|
||||
|
||||
@@ -4944,11 +5147,7 @@ function onMicroDown(e) {
|
||||
charPlaceMode = false;
|
||||
return;
|
||||
}
|
||||
// 选中角色后:点击地面直接移动(无需再点按钮)
|
||||
if (typeof selectedCharId !== 'undefined' && selectedCharId && typeof setCharTarget === 'function') {
|
||||
setCharTarget(selectedCharId, w.x, w.y);
|
||||
return;
|
||||
}
|
||||
// (已移除)玩家点击地面控制生物移动:selectChar 后点击地面不再 setCharTarget(按需求取消玩家移动控制);保留选中/打开面板能力
|
||||
}
|
||||
// 放大状态下(zoom>1)左键拖拽 = 平移查看浮岛内部细节;
|
||||
// 仅初始大小(zoom=1)时左键拖拽才是滑动切相邻地块
|
||||
@@ -5187,6 +5386,7 @@ function setMicroTile(tile) {
|
||||
microAutoRoadCells(); // 切地块时重新计算自动道路贴图
|
||||
microSanitizeBuildings(); // 切地块时清理越界建筑
|
||||
microRefreshResources(); // 切地块时刷新建筑囤积徽标缓存
|
||||
microLoadChests(tile.q, tile.r); // 切地块时刷新该地块宝箱
|
||||
}
|
||||
// 快照旧视图 → 切换地块 → 重建缓存 → 启动滑入过渡
|
||||
function transitionMicroTo(tile, dx, dy) {
|
||||
@@ -6869,6 +7069,7 @@ function exitMicroWorld() {
|
||||
microBuildRevealAt = 0;
|
||||
microZoom = 1; microPanX = 0; microPanY = 0;
|
||||
microPanning = false; microPanStart = null;
|
||||
microStopChestGen(); // 停止测试期生成定时器 + 清空宝箱/飘字
|
||||
microViewOpen = false; // 恢复主世界重绘
|
||||
}
|
||||
|
||||
@@ -8176,7 +8377,7 @@ window.addEventListener('keydown', e => {
|
||||
// 进入时一次性把世界铺满视口并钳制边界(fitWorld 内部已调 clampCamera)。
|
||||
if (typeof fitWorld === 'function') fitWorld();
|
||||
</script>
|
||||
<script src="assets/game-api.js?v=20260825"></script>
|
||||
<script src="assets/game-api.js?v=20260910"></script>
|
||||
<script src="assets/common.js?v=20260825"></script>
|
||||
|
||||
<!-- ===== 我的天赋面板(顶栏 🌟 按钮触发,展示玩家已拥有的天赋) ===== -->
|
||||
@@ -8604,7 +8805,7 @@ if (typeof fitWorld === 'function') fitWorld();
|
||||
}catch(e){}
|
||||
}
|
||||
</script>
|
||||
<script src="assets/characters.js?v=20260825"></script>
|
||||
<script src="assets/characters.js?v=20260911"></script>
|
||||
<script>
|
||||
// ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 =====
|
||||
async function startGameGate() {
|
||||
|
||||