散落物系统 #35

Merged
Ghost merged 1 commits from why_ga into main 2026-09-10 19:24:21 +08:00
45 changed files with 761 additions and 56 deletions
+1
View File
@@ -27,6 +27,7 @@
'views/npc_knowledge_views.xml', 'views/npc_knowledge_views.xml',
'views/npc_views.xml', 'views/npc_views.xml',
'views/npc_menu.xml', 'views/npc_menu.xml',
'views/yt_world_chest_views.xml',
], ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [
+1
View File
@@ -1,2 +1,3 @@
from . import world_api from . import world_api
from . import npc_api from . import npc_api
from . import chest_api
+129
View File
@@ -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)
+30
View File
@@ -128,6 +128,36 @@
<field name="sequence">210</field> <field name="sequence">210</field>
</record> </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"> <record id="res_plank" model="yt.codex.resource">
<field name="code">plank</field><field name="name">木板</field><field name="icon">📦</field> <field name="code">plank</field><field name="name">木板</field><field name="icon">📦</field>
+1
View File
@@ -16,3 +16,4 @@ from . import npc
from . import npc_relationship from . import npc_relationship
from . import npc_behavior from . import npc_behavior
from . import npc_knowledge from . import npc_knowledge
from . import yt_world_chest
+1
View File
@@ -14,6 +14,7 @@ class YtCodexResource(models.Model):
category = fields.Selection([ category = fields.Selection([
('material', '材料'), ('material', '材料'),
('processed', '加工品'), ('processed', '加工品'),
('scatter', '散落物'),
('special', '特殊'), ('special', '特殊'),
], string='分类', required=True, default='material') ], string='分类', required=True, default='material')
quality = fields.Selection([ quality = fields.Selection([
+213
View File
@@ -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_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_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_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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
59 access_yt_world_task_user yt.world.task user model_yt_world_task base.group_user 1 1 1 1
60 access_yt_world_task_public yt.world.task public model_yt_world_task base.group_public 1 0 0 0
61 access_yt_world_task_portal yt.world.task portal model_yt_world_task base.group_portal 1 0 0 0
62 access_yt_world_chest_user yt.world.chest user model_yt_world_chest base.group_user 1 1 1 1
63 access_yt_world_chest_public yt.world.chest public model_yt_world_chest base.group_public 1 0 0 0
64 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>
+63 -35
View File
@@ -493,8 +493,17 @@ function updateChars(dt) {
return; return;
} }
// 键盘 WASD 控制(仅选中角色):8 方向实时移动,优先级高于点击移动/漫游 // 宝箱目标失效(被其他角色开启或切地块清空):重置为 idle
const kbdDir = ch.selected ? wasdDirection() : null; 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) { if (kbdDir) {
// 键盘移动恒用种族基础移速(不受漫游/点击移动改过的 ch.speed 残留影响) // 键盘移动恒用种族基础移速(不受漫游/点击移动改过的 ch.speed 残留影响)
const kbdSpeed = raceStats(ch.race).speed; const kbdSpeed = raceStats(ch.race).speed;
@@ -522,32 +531,61 @@ function updateChars(dt) {
// 原有漫游 / 行走逻辑 // 原有漫游 / 行走逻辑
// 自由移动开关:ch.freeMove !== false 才允许自主漫游(默认开,可被面板按钮关闭) // 自由移动开关:ch.freeMove !== false 才允许自主漫游(默认开,可被面板按钮关闭)
if (ch.freeMove !== false && !ch.selected && ch.state === 'idle') { if (ch.freeMove !== false && !ch.selected && ch.state === 'idle') {
ch.wanderT = (ch.wanderT || 0) + dt; // 宝箱吸引:附近存在活跃宝箱时优先去开启(测试阶段吸引半径全屏)
const interval = ch.wanderInterval || (2 + Math.random() * 4); const chests = window.microChests;
if (ch.wanderT >= interval) { const c2s = window.microCellToScreen;
ch.wanderT = 0; let best = null, bestD = Infinity;
ch.wanderInterval = 2 + Math.random() * 4; if (chests && c2s && chests.length) {
// 多意图漫游目标:闲逛 / 朝中心 / 远行(带岛内+建筑校验) for (const chest of chests) {
const target = generateWanderTarget(ch); if (chest._claiming) continue;
if (!target) { const sp = c2s(chest.cell_i + 0.5, chest.cell_j + 0.5);
ch.wanderInterval = 1 + Math.random() * 2; // 找不到就短等重试 const d = Math.hypot(sp.x - ch.x, sp.y - ch.y);
return; if (d < bestD) { bestD = d; best = { chest, sp }; }
} }
ch.targetX = target.tx; }
ch.targetY = target.ty; if (best && bestD < 2000) { // 吸引半径:2000px 已覆盖整个浮岛视图
ch.state = 'walk'; ch._chestTarget = best.chest;
ch.frame = 0; ch.targetX = best.sp.x;
ch.frameT = 0; ch.targetY = best.sp.y;
ch.speed = 35 + Math.random() * 20; // 慢速:约全速的一半 ch.state = 'walk';
ch._userMove = false; ch.frame = 0; ch.frameT = 0;
ch.trail = []; 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 块的直接子块:键盘移动激活时不会执行) // 行走逻辑(else 块的直接子块:键盘移动激活时不会执行)
if (ch.state === 'walk') { if (ch.state === 'walk') {
const dx = ch.targetX - ch.x, dy = ch.targetY - ch.y; const dx = ch.targetX - ch.x, dy = ch.targetY - ch.y;
const dist = Math.hypot(dx, dy); 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.state = 'idle';
ch.frame = 0; ch.frame = 0;
ch.frameT = 0; ch.frameT = 0;
@@ -877,19 +915,9 @@ function hitChar(wx, wy) {
return null; return null;
} }
// ---- 限制在浮岛椭圆内 ---- // ---- 限制在浮岛椭圆内(已移除:用户要求生物可走到视图任意位置,不再卡边界) ----
function clampCharToIsland(ch) { function clampCharToIsland(ch) {
if (typeof microCanvas === 'undefined' || !microCanvas) return; // no-op: boundary restriction removed; characters can roam freely within the micro canvas
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 面板 ---- // ---- UI 面板 ----
+44
View File
@@ -446,4 +446,48 @@ const API = {
return { ok: false, error: 'network', detail: String(e && e.message || e) }; 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) };
}
},
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 853 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 945 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 622 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1002 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1019 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1014 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 891 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 706 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 467 KiB

-1
View File
@@ -55,7 +55,6 @@ document.querySelectorAll('.world-nav button[data-panel]').forEach(b => {
// ---------- 程序化矢量缩略图(零 AI 生图,不消耗积分) ---------- // ---------- 程序化矢量缩略图(零 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 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;};} 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 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 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;} 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;}
+7 -5
View File
@@ -153,10 +153,11 @@ body::before{
.card.race .tags{display:flex;gap:4px;flex-wrap:wrap;justify-content:center;margin-top:2px;} .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.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 .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{font-size:9px;padding:1px 6px;border-radius:4px;background:rgba(255,255,255,.06);color:#8899aa;} .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.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;} .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="skill_book" onclick="setCat('skill_book')">📖 技能书</span>
<span class="rule-tab" data-cat="book" onclick="setCat('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="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> <span class="rule-tab" data-cat="special" onclick="setCat('special')">✨ 特殊</span>
</div> </div>
<div class="filterRow"> <div class="filterRow">
@@ -339,7 +341,7 @@ body::before{
const QUALITIES = ['common','uncommon','rare','epic','legendary','mythic']; const QUALITIES = ['common','uncommon','rare','epic','legendary','mythic'];
const QUALITY_NAMES = { common:'普通', uncommon:'优秀', rare:'精良', epic:'史诗', legendary:'传说', mythic:'神话' }; const QUALITY_NAMES = { common:'普通', uncommon:'优秀', rare:'精良', epic:'史诗', legendary:'传说', mythic:'神话' };
const TIER_NAMES = { normal:'普通野怪', elite:'精英野怪', boss:'Boss' }; 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:'鞋子' }; const SLOT_NAMES = { weapon:'武器', armor:'护甲', accessory:'饰品', boots:'鞋子' };
// 当前玩家已拥有天赋(从后端加载){ code: { level, active } } // 当前玩家已拥有天赋(从后端加载){ 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 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'; 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>`; 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>`; html += `<span class="mt">${catLabel}</span>`;
} else if(e.type==='creature'){ } 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>`; html += `<span class="mt">🐾 生物</span><span class="mt">${e.hab||''}</span><span class="mt">${e.diet||''}</span><span class="mt">${e.tame?'✅ 可驯养':'❌ 野性'}</span>`;
+216 -15
View File
@@ -1519,6 +1519,10 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<span class="top-icon-label">成就</span> <span class="top-icon-label">成就</span>
<span class="top-icon-badge" id="achBadge" hidden></span> <span class="top-icon-badge" id="achBadge" hidden></span>
</button> </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> </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="高度"> <img src="assets/icons/height/height.png?v=2" alt="高度">
<span class="qi-label">高度</span> <span class="qi-label">高度</span>
</button> </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>
<div class="bb-slots" style="display:none;"> <div class="bb-slots" style="display:none;">
<button class="bb-btn" id="btnManage" type="button" style="display:none;">⚙️ 管理</button> <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 microViewOpen = false; // 微观视图是否打开:打开时主世界 render 暂停重绘,避免双循环抢 CPU
let microHoverB = null; // 鼠标当前悬停的建筑(已过滤 road/ground);非空时在渲染层额外绘高亮 footprint 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 microBuildOpenT = 0; // 0=收起 1=展开(每帧缓动逼近)
let microBuildScale = 1; // 当前世界缩放系数(建造展开时 <1) let microBuildScale = 1; // 当前世界缩放系数(建造展开时 <1)
@@ -2746,6 +2755,150 @@ async function microLoadGame() {
} catch (e) { /* 离线兜底:不阻塞微观世界 */ } } 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 归属地块) ===== // ===== 玩家生物持久化恢复(生物属于玩家账号,存于 yt.world.creature,按 tile_ref 归属地块) =====
// 服务端生物记录 → 地块居民 unit 对象 // 服务端生物记录 → 地块居民 unit 对象
function microMakeUnitFromCreature(x) { function microMakeUnitFromCreature(x) {
@@ -4256,6 +4409,8 @@ function enterMicroWorld(tile) {
microLoadIcons(); // 进入微观视图即预加载建造贴图,避免已放置建筑先显示文字 microLoadIcons(); // 进入微观视图即预加载建造贴图,避免已放置建筑先显示文字
microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效 microLoadCatalog(); // 拉取 Odoo 后台建造目录(未登录时保留前端兜底常量),hover/弹窗即时生效
microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态 microLoadGame(); // 拉取角色 / 生物 / 订单(Odoo 后端),覆盖单位状态
microLoadChests(tile.q, tile.r); // 拉取本地块已生成的宝箱
microStartChestGen(); // 启动测试期 10s 生成定时器
fetchTasks(); // 拉取任务定义 + 角色进度(新手主线) fetchTasks(); // 拉取任务定义 + 角色进度(新手主线)
// 资源系统:登录时一次性结算离线产出并兜底回库,再启动在线自动进账 // 资源系统:登录时一次性结算离线产出并兜底回库,再启动在线自动进账
microSettle(true, true).then(() => microRefreshResources()); 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 padX = Rx * 0.12, padY = Ry * 0.12, minD = Math.min(Rx, Ry) * 0.24;
const roadSet = microRoadKeySet(); const roadSet = microRoadKeySet();
const onRoad = roadSet.size > 0; const onRoad = roadSet.size > 0;
// 宝箱层(建筑之上、居民之下)
microDrawChests(microCtx);
if (!hasBgImage) { if (!hasBgImage) {
microState.units.forEach((u, k) => { microState.units.forEach((u, k) => {
// 由建筑层负责渲染的种族角色不再在这里画 emoji,避免重影 // 由建筑层负责渲染的种族角色不再在这里画 emoji,避免重影
@@ -4723,14 +4880,58 @@ function startMicroRender() {
px = a.x + (b.x - a.x) * e; py = a.y + (b.y - a.y) * e; 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; 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); 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 { } else {
const h = microHash(u.id); const h = microHash(u.id);
const rx1 = ((h >> 2) % 100) / 100; const rx1 = ((h >> 2) % 100) / 100;
const ry1 = ((h >> 9) % 100) / 100; const ry1 = ((h >> 9) % 100) / 100;
px = cx + (rx1 * 2 - 1) * (Rx - padX); const homeX = cx + (rx1 * 2 - 1) * (Rx - padX);
py = cy + (ry1 * 2 - 1) * (Ry - padY); const homeY = cy + (ry1 * 2 - 1) * (Ry - padY);
const dx_ = px - cx, dy_ = py - cy, d = Math.hypot(dx_, dy_); const dxh = homeX - cx, dyh = homeY - cy, dh = Math.hypot(dxh, dyh);
if (d < minD && d > 0.001) { const kk = minD / d; px = cx + dx_ * kk; py = cy + dy_ * kk; } 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(缓动上下浮动)+ 呼吸脉冲(停步时) // 行走 bob(缓动上下浮动)+ 呼吸脉冲(停步时)
const bob = moving ? Math.abs(Math.sin(microState.animTime * 6 + k)) * (Math.min(Rx, Ry) * 0.03) : 0; 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); if (!hasBgImage) drawMicroAtmoParticles(microCtx, mW, mH, terrain);
// 开启飘字(仍在变换内,跟随缩放)
microDrawFloatTexts(microCtx, dt);
// ---- 结束变换:恢复画布坐标系,绘制 UI 层不受 zoom 影响 ---- // ---- 结束变换:恢复画布坐标系,绘制 UI 层不受 zoom 影响 ----
microCtx.restore(); microCtx.restore();
@@ -4944,11 +5147,7 @@ function onMicroDown(e) {
charPlaceMode = false; charPlaceMode = false;
return; return;
} }
// 选中角色后:点击地面直接移动(无需再点按钮) // (已移除)玩家点击地面控制生物移动:selectChar 后点击地面不再 setCharTarget(按需求取消玩家移动控制);保留选中/打开面板能力
if (typeof selectedCharId !== 'undefined' && selectedCharId && typeof setCharTarget === 'function') {
setCharTarget(selectedCharId, w.x, w.y);
return;
}
} }
// 放大状态下(zoom>1)左键拖拽 = 平移查看浮岛内部细节; // 放大状态下(zoom>1)左键拖拽 = 平移查看浮岛内部细节;
// 仅初始大小(zoom=1)时左键拖拽才是滑动切相邻地块 // 仅初始大小(zoom=1)时左键拖拽才是滑动切相邻地块
@@ -5187,6 +5386,7 @@ function setMicroTile(tile) {
microAutoRoadCells(); // 切地块时重新计算自动道路贴图 microAutoRoadCells(); // 切地块时重新计算自动道路贴图
microSanitizeBuildings(); // 切地块时清理越界建筑 microSanitizeBuildings(); // 切地块时清理越界建筑
microRefreshResources(); // 切地块时刷新建筑囤积徽标缓存 microRefreshResources(); // 切地块时刷新建筑囤积徽标缓存
microLoadChests(tile.q, tile.r); // 切地块时刷新该地块宝箱
} }
// 快照旧视图 → 切换地块 → 重建缓存 → 启动滑入过渡 // 快照旧视图 → 切换地块 → 重建缓存 → 启动滑入过渡
function transitionMicroTo(tile, dx, dy) { function transitionMicroTo(tile, dx, dy) {
@@ -6869,6 +7069,7 @@ function exitMicroWorld() {
microBuildRevealAt = 0; microBuildRevealAt = 0;
microZoom = 1; microPanX = 0; microPanY = 0; microZoom = 1; microPanX = 0; microPanY = 0;
microPanning = false; microPanStart = null; microPanning = false; microPanStart = null;
microStopChestGen(); // 停止测试期生成定时器 + 清空宝箱/飘字
microViewOpen = false; // 恢复主世界重绘 microViewOpen = false; // 恢复主世界重绘
} }
@@ -8176,7 +8377,7 @@ window.addEventListener('keydown', e => {
// 进入时一次性把世界铺满视口并钳制边界(fitWorld 内部已调 clampCamera)。 // 进入时一次性把世界铺满视口并钳制边界(fitWorld 内部已调 clampCamera)。
if (typeof fitWorld === 'function') fitWorld(); if (typeof fitWorld === 'function') fitWorld();
</script> </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> <script src="assets/common.js?v=20260825"></script>
<!-- ===== 我的天赋面板(顶栏 🌟 按钮触发,展示玩家已拥有的天赋) ===== --> <!-- ===== 我的天赋面板(顶栏 🌟 按钮触发,展示玩家已拥有的天赋) ===== -->
@@ -8604,7 +8805,7 @@ if (typeof fitWorld === 'function') fitWorld();
}catch(e){} }catch(e){}
} }
</script> </script>
<script src="assets/characters.js?v=20260825"></script> <script src="assets/characters.js?v=20260911"></script>
<script> <script>
// ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 ===== // ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 =====
async function startGameGate() { async function startGameGate() {