散落物系统

This commit is contained in:
李鹏宇
2026-09-10 19:23:18 +08:00
parent 0a57b9fe99
commit 8a10700008
45 changed files with 761 additions and 56 deletions
+1
View File
@@ -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
View File
@@ -1,2 +1,3 @@
from . import world_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>
</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>
+1
View File
@@ -16,3 +16,4 @@ from . import npc
from . import npc_relationship
from . import npc_behavior
from . import npc_knowledge
from . import yt_world_chest
+1
View File
@@ -14,6 +14,7 @@ class YtCodexResource(models.Model):
category = fields.Selection([
('material', '材料'),
('processed', '加工品'),
('scatter', '散落物'),
('special', '特殊'),
], string='分类', required=True, default='material')
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_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
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>