diff --git a/addons/game_base/views/game_base_views.xml b/addons/game_base/views/game_base_views.xml index 90a90fd..d363075 100644 --- a/addons/game_base/views/game_base_views.xml +++ b/addons/game_base/views/game_base_views.xml @@ -1,8 +1,9 @@ - - + + diff --git a/addons/yt_world/__init__.py b/addons/yt_world/__init__.py new file mode 100644 index 0000000..f7209b1 --- /dev/null +++ b/addons/yt_world/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/addons/yt_world/__manifest__.py b/addons/yt_world/__manifest__.py new file mode 100644 index 0000000..5844ba5 --- /dev/null +++ b/addons/yt_world/__manifest__.py @@ -0,0 +1,14 @@ +{ + 'name': '宇森世界 - 沙盘与微观世界', + 'version': '0.2', + 'summary': '地表存储(沙盘记忆) + 微观世界(1:1预留) + 资源(框架)', + 'description': '每个玩家拥有一个确定性生成的世界;仅持久化玩家改过的地表地块(种子+增量覆盖)。微观世界独立为 1:1 模型仅预留,资源模型为框架。', + 'category': 'Game', + 'depends': ['base'], + 'data': [ + 'security/ir.model.access.csv', + 'views/yt_world_views.xml', + ], + 'application': True, + 'license': 'AGPL-3', +} diff --git a/addons/yt_world/controllers/__init__.py b/addons/yt_world/controllers/__init__.py new file mode 100644 index 0000000..5838dba --- /dev/null +++ b/addons/yt_world/controllers/__init__.py @@ -0,0 +1 @@ +from . import world_api diff --git a/addons/yt_world/controllers/world_api.py b/addons/yt_world/controllers/world_api.py new file mode 100644 index 0000000..bcfaa0c --- /dev/null +++ b/addons/yt_world/controllers/world_api.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +import json +from odoo import http +from odoo.http import request, Response + + +def _json(data): + """返回 UTF-8 JSON 响应(与 game_base 一致)。""" + return Response( + json.dumps(data, ensure_ascii=False), + content_type='application/json; charset=utf-8', + ) + + +def _post_body(): + try: + raw = request.httprequest.get_data(as_text=True) or '{}' + return json.loads(raw) + except Exception: + return {} + + +class YtWorldApi(http.Controller): + """前端沙盘记忆接口(JSON over HTTP)。auth=user:需已登录 Odoo。 + 前端统一带 ?db=game 且 credentials:'same-origin',故路由须为 type='http' + 并以 _json 返回原始 JSON(与 game-api.js 的 fetch().json() 对齐)。""" + + @http.route('/yt_world/api/world', type='http', auth='user', + methods=['GET', 'OPTIONS'], csrf=False) + def world(self, **kw): + """取或建当前用户世界,导出 seed + 地块覆盖。""" + user = request.env.user + rec = request.env['yt.world'].sudo().get_or_create_for_user(user) + return _json(rec.export_state()) + + @http.route('/yt_world/api/tiles/save', type='http', auth='user', + methods=['POST', 'OPTIONS'], csrf=False) + def save_tiles(self, **kw): + """增量覆盖保存地块。body: {"tiles": [...]}""" + user = request.env.user + world = request.env['yt.world'].sudo().search( + [('user_id', '=', user.id)], limit=1) + if not world: + return _json({'ok': False, 'error': 'no_world'}) + body = _post_body() + tiles = body.get('tiles') or [] + world.import_tiles(tiles) + return _json({'ok': True, 'saved': len(tiles)}) + + @http.route('/yt_world/api/settings', type='http', auth='user', + methods=['POST', 'OPTIONS'], csrf=False) + def save_settings(self, **kw): + """保存相机/UI 偏好。body: {"settings": {...}}""" + user = request.env.user + world = request.env['yt.world'].sudo().search( + [('user_id', '=', user.id)], limit=1) + body = _post_body() + settings = body.get('settings') + if world and settings is not None: + world.settings = settings + return _json({'ok': True}) diff --git a/addons/yt_world/models/__init__.py b/addons/yt_world/models/__init__.py new file mode 100644 index 0000000..de42408 --- /dev/null +++ b/addons/yt_world/models/__init__.py @@ -0,0 +1,4 @@ +from . import yt_world +from . import yt_world_tile +from . import yt_world_tile_micro +from . import yt_resource diff --git a/addons/yt_world/models/constants.py b/addons/yt_world/models/constants.py new file mode 100644 index 0000000..67c8618 --- /dev/null +++ b/addons/yt_world/models/constants.py @@ -0,0 +1,15 @@ +TERRAIN_SELECTION = [ + ('water', '水域'), + ('desert', '荒漠'), + ('plain', '平原'), + ('forest', '森林'), + ('mountain', '山脉'), + ('snow', '雪地'), +] + +RESOURCE_TYPE_SELECTION = [ + ('wood', '木材'), + ('food', '食物'), + ('stone', '石料'), + ('fish', '鱼获'), +] diff --git a/addons/yt_world/models/yt_resource.py b/addons/yt_world/models/yt_resource.py new file mode 100644 index 0000000..ad692d1 --- /dev/null +++ b/addons/yt_world/models/yt_resource.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields +from .constants import RESOURCE_TYPE_SELECTION + + +class YtResource(models.Model): + """资源(框架模型)。本期仅结构,不接入玩法产出/消耗。""" + _name = 'yt.resource' + _description = '资源(框架)' + + tile_id = fields.Many2one('yt.world.tile', string='所属地块', ondelete='cascade') + micro_id = fields.Many2one('yt.world.tile.micro', string='所属微观世界', ondelete='cascade') + type = fields.Selection(RESOURCE_TYPE_SELECTION, string='资源类型') + amount = fields.Float(string='数量', default=0.0) + capacity = fields.Float(string='容量上限', default=100.0) # [PLACEHOLDER] 调参 + + _sql_constraints = [ + ('uniq_res_tile', 'unique(tile_id, type)', + '同地块同类型资源唯一'), + ] diff --git a/addons/yt_world/models/yt_world.py b/addons/yt_world/models/yt_world.py new file mode 100644 index 0000000..cd9bfff --- /dev/null +++ b/addons/yt_world/models/yt_world.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields +import random + + +class YtWorld(models.Model): + """玩家世界:每个用户一条。seed 再生基础地图,tile_ids 存被改地块。""" + _name = 'yt.world' + _description = '玩家世界' + + name = fields.Char(string='世界名称', translate=True) + user_id = fields.Many2one('res.users', string='拥有者', + required=True, ondelete='cascade', index=True) + seed = fields.Integer(string='生成种子', required=True, index=True) + world_version = fields.Integer(string='生成算法版本', default=1) + settings = fields.Json(string='世界设置') # 相机位/缩放/UI 偏好 + tile_ids = fields.One2many('yt.world.tile', 'world_id', string='地表地块') + created_at = fields.Datetime(string='创建时间', default=fields.Datetime.now) + last_active = fields.Datetime(string='最后活跃') + + # ---------- 接口方法(供 controller 调用) ---------- + + def get_or_create_for_user(self, user): + """取当前用户的世界;没有则按随机 seed 新建。""" + rec = self.search([('user_id', '=', user.id)], limit=1) + if not rec: + rec = self.create({ + 'user_id': user.id, + 'seed': random.randint(1, 2_000_000_000), + 'name': '我的世界', + }) + rec.last_active = fields.Datetime.now() + return rec + + def export_state(self): + """导出前端需要的世界状态:seed + 地块覆盖列表。""" + self.ensure_one() + tiles = [] + for t in self.tile_ids: + tiles.append({ + 'q': t.q, + 'r': t.r, + 'terrain': t.terrain or None, + 'height': t.height or None, + 'flags': t.flags or {}, + 'micro_world_id': t.micro_world_id.id if t.micro_world_id else None, + }) + return { + 'world_id': self.id, + 'seed': self.seed, + 'world_version': self.world_version, + 'settings': self.settings or {}, + 'tiles': tiles, + } + + def import_tiles(self, tiles): + """增量覆盖:upsert 地块。terrain/height/flags 全回退底图则删除覆盖行。""" + self.ensure_one() + tile_model = self.env['yt.world.tile'] + for tv in (tiles or []): + q, r = tv.get('q'), tv.get('r') + if q is None or r is None: + continue + terrain = tv.get('terrain') + height = tv.get('height') + flags = tv.get('flags') + # 全部回退底图 → 删覆盖行,保持最小集(种子+增量覆盖原则) + if not terrain and height is None and not (flags or {}): + existing = tile_model.search([ + ('world_id', '=', self.id), ('q', '=', q), ('r', '=', r)]) + if existing: + existing.unlink() + continue + vals = {} + if terrain is not None: + vals['terrain'] = terrain + if height is not None: + vals['height'] = height + if flags is not None: + vals['flags'] = flags + existing = tile_model.search([ + ('world_id', '=', self.id), ('q', '=', q), ('r', '=', r)]) + if existing: + existing.write(vals) + else: + vals.update({'q': q, 'r': r, 'world_id': self.id}) + tile_model.create(vals) # create 钩子自动建微观占位 + self.last_active = fields.Datetime.now() diff --git a/addons/yt_world/models/yt_world_tile.py b/addons/yt_world/models/yt_world_tile.py new file mode 100644 index 0000000..1937f30 --- /dev/null +++ b/addons/yt_world/models/yt_world_tile.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields, api +from .constants import TERRAIN_SELECTION + + +class YtWorldTile(models.Model): + """地表地块:本期实际持久化内容(坐标 + 高度 + 属性 + 微观世界关联位)。""" + _name = 'yt.world.tile' + _description = '地表地块' + _rec_name = 'coord' + + world_id = fields.Many2one('yt.world', string='所属世界', + required=True, ondelete='cascade') + q = fields.Integer(string='轴向坐标Q', required=True) + r = fields.Integer(string='轴向坐标R', required=True) + terrain = fields.Selection(TERRAIN_SELECTION, string='地形', index=True) + height = fields.Integer(string='高度等级') # heightLevel 1-12 + flags = fields.Json(string='地表标记') # {claimed, decorated} + micro_world_id = fields.Many2one('yt.world.tile.micro', + string='微观世界', ondelete='set null') + updated_at = fields.Datetime(string='更新时间', default=fields.Datetime.now) + coord = fields.Char(string='坐标标识', compute='_compute_coord', store=True) + + _sql_constraints = [ + ('uniq_tile', 'unique(world_id, q, r)', + '同一世界内 (坐标) 的地块必须唯一'), + ] + + @api.depends('q', 'r') + def _compute_coord(self): + for t in self: + t.coord = "%s,%s" % (t.q, t.r) + + @api.model + def create(self, vals): + rec = super(YtWorldTile, self).create(vals) + rec._ensure_micro() + return rec + + def _ensure_micro(self): + """新建地块时同步建 1:1 微观世界占位。""" + for t in self: + if not t.micro_world_id: + m = self.env['yt.world.tile.micro'].create({'tile_id': t.id}) + t.micro_world_id = m + + @api.model + def write(self, vals): + # 任何编辑都刷新 updated_at + if 'updated_at' not in vals: + vals['updated_at'] = fields.Datetime.now() + return super(YtWorldTile, self).write(vals) diff --git a/addons/yt_world/models/yt_world_tile_micro.py b/addons/yt_world/models/yt_world_tile_micro.py new file mode 100644 index 0000000..811609c --- /dev/null +++ b/addons/yt_world/models/yt_world_tile_micro.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from odoo import models, fields + + +class YtWorldTileMicro(models.Model): + """微观世界(预留 1:1 模型)。当前仅骨架,内容与资源后续开发。""" + _name = 'yt.world.tile.micro' + _description = '微观世界(预留)' + + tile_id = fields.Many2one('yt.world.tile', string='所属地块', + required=True, ondelete='cascade') + name = fields.Char(string='微观世界名称', translate=True) + # ===== 预留字段(后续放开,本期不实现) ===== + # people = fields.Json(string='居民') # [{id, name, job, mood, x, y}] + # buildings = fields.Json(string='建筑') # [{type, level, x, y, rot}] + # resources = fields.Json(string='资源') # {wood, food, stone, fish} + # =========================================== + + _sql_constraints = [ + ('uniq_micro', 'unique(tile_id)', + '一个地块只能有一个微观世界(一对一)'), + ] diff --git a/addons/yt_world/security/ir.model.access.csv b/addons/yt_world/security/ir.model.access.csv new file mode 100644 index 0000000..3cbaddd --- /dev/null +++ b/addons/yt_world/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_yt_world,yt.world,model_yt_world,base.group_user,1,1,1,1 +access_yt_world_tile,yt.world.tile,model_yt_world_tile,base.group_user,1,1,1,1 +access_yt_world_tile_micro,yt.world.tile.micro,model_yt_world_tile_micro,base.group_user,1,1,1,1 +access_yt_resource,yt.resource,model_yt_resource,base.group_user,1,1,1,1 diff --git a/addons/yt_world/static/description/icon.png b/addons/yt_world/static/description/icon.png new file mode 100644 index 0000000..4561b42 Binary files /dev/null and b/addons/yt_world/static/description/icon.png differ diff --git a/addons/yt_world/views/yt_world_views.xml b/addons/yt_world/views/yt_world_views.xml new file mode 100644 index 0000000..92aa1b9 --- /dev/null +++ b/addons/yt_world/views/yt_world_views.xml @@ -0,0 +1,111 @@ + + + + + + + + + 玩家世界 + yt.world + list,form + + + + yt.world.list + yt.world + + + + + + + + + + + + + yt.world.form + yt.world + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + + + + 地表地块 + yt.world.tile + list,form + + + + yt.world.tile.list + yt.world.tile + + + + + + + + + + + + + + + yt.world.tile.form + yt.world.tile + +
+ + + + + + + + + + + + +
+
+
+ +
diff --git a/assets/game-api.js b/assets/game-api.js index d114327..f057274 100644 --- a/assets/game-api.js +++ b/assets/game-api.js @@ -39,4 +39,22 @@ const API = { async announcements() { return (await fetch('/game/api/announcements?db=game', { credentials: 'same-origin' })).json(); }, + // ---------- 沙盘记忆(yt_world 模块) ---------- + async world() { + return (await fetch('/yt_world/api/world?db=game', { credentials: 'same-origin' })).json(); + }, + async saveTiles(tiles) { + return (await fetch('/yt_world/api/tiles/save?db=game', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ tiles }), + })).json(); + }, + async saveSettings(settings) { + return (await fetch('/yt_world/api/settings?db=game', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ settings }), + })).json(); + }, }; diff --git a/index.html b/index.html index 6c5b90f..3ff0dc2 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,9 @@ + + +