散落物系统
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user