diff --git a/addons/game_base/controllers/game_api.py b/addons/game_base/controllers/game_api.py index 444330d..9d6ed42 100644 --- a/addons/game_base/controllers/game_api.py +++ b/addons/game_base/controllers/game_api.py @@ -183,7 +183,11 @@ class GameApiController(http.Controller): password = data.get('password') or '' if not login or not password: return _json({'ok': False, 'error': '账号和密码必填'}) - uid = request.session.authenticate(db, login, password) + try: + auth_info = request.session.authenticate(db, {'type': 'password', 'login': login, 'password': password}) + uid = auth_info['uid'] + except Exception: + return _json({'ok': False, 'error': '账号或密码错误'}) if uid: user = request.env['res.users'].sudo().browse(uid) return _json({'ok': True, 'uid': uid, 'name': user.name, 'login': user.login}) @@ -213,8 +217,10 @@ class GameApiController(http.Controller): 'password': password, 'groups_id': [(6, 0, [portal.id])], }) + request.env.cr.commit() # 让新用户立即对其他游标可见,便于随后自动登录 db = request.session.db or 'game' - uid = request.session.authenticate(db, login, password) + auth_info = request.session.authenticate(db, {'type': 'password', 'login': login, 'password': password}) + uid = auth_info['uid'] if uid: return _json({'ok': True, 'uid': uid, 'name': user.name, 'login': user.login}) return _json({'ok': True, 'uid': user.id, 'name': user.name, 'login': user.login}) diff --git a/addons/game_base/static/description/icon.png b/addons/game_base/static/description/icon.png new file mode 100644 index 0000000..c4cf0a7 Binary files /dev/null and b/addons/game_base/static/description/icon.png differ diff --git a/addons/game_base/static/description/index.html b/addons/game_base/static/description/index.html new file mode 100644 index 0000000..013833d --- /dev/null +++ b/addons/game_base/static/description/index.html @@ -0,0 +1,112 @@ + + + + + Yusen Game Base + + + +
+ Yusen Game Base logo +
+

Yusen Game Base

+

Game 宇森沙盘游戏后台基础模块

+
+
+ +

沉淀游戏世界观内容,为前端 /game/api/* 提供只读数据;同时打通 Odoo res.users,提供玩家登录/注册能力。

+ +
+

数据模型

+ +
+ +
+

前端 API

+ +
+ +
+

依赖

+

仅依赖 base,安装简单,无额外第三方模块。

+
+ + diff --git a/addons/game_base/static/description/logo.png b/addons/game_base/static/description/logo.png new file mode 100644 index 0000000..e231f1d Binary files /dev/null and b/addons/game_base/static/description/logo.png differ diff --git a/addons/game_base/views/game_base_views.xml b/addons/game_base/views/game_base_views.xml index 503195f..bbb79ff 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 f047e6d..b187954 100644 --- a/assets/game-api.js +++ b/assets/game-api.js @@ -58,4 +58,22 @@ const API = { async announcements() { return (await fetch(_url('/game/api/announcements'), { credentials: _cred })).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 2a35e57..0865337 100644 --- a/index.html +++ b/index.html @@ -4,9 +4,12 @@ 宇森 · Yusen|活的文明系统模拟 - - - + + + + + + @@ -71,7 +72,6 @@
-
@@ -81,32 +81,41 @@
- +
- +
+ + +
@@ -128,8 +137,6 @@ // 回跳目标(进入游戏流程带 ?next=world.html) const params = new URLSearchParams(location.search); const nextUrl = params.get('next') || 'world.html'; - const hint = document.getElementById('hint'); - if (params.get('next')) hint.classList.remove('hidden'); // 已登录则直接跳走 (async () => { try { const me = await API.me(); if (me && me.uid) location.href = nextUrl; } catch (e) {} @@ -149,6 +156,34 @@ tabLogin.onclick = () => switchTab('login'); tabReg.onclick = () => switchTab('reg'); + // 密码显示/隐藏切换(眼睛按钮) + document.querySelectorAll('.pw-toggle').forEach(btn => { + btn.addEventListener('click', () => { + const input = document.getElementById(btn.dataset.target); + if (!input) return; + if (input.type === 'password') { + input.type = 'text'; + btn.textContent = '🙈'; + btn.setAttribute('aria-label', '隐藏密码'); + } else { + input.type = 'password'; + btn.textContent = '👁'; + btn.setAttribute('aria-label', '显示密码'); + } + input.focus(); + }); + }); + + // 昵称默认 = 账号:账号变更时若昵称仍空/等于上次账号值,则同步;用户改过昵称则保留 + const rLogin = document.getElementById('rLogin'); + const rName = document.getElementById('rName'); + let lastLoginVal = ''; + rLogin.addEventListener('input', () => { + const v = rLogin.value.trim(); + if (rName.value === '' || rName.value === lastLoginVal) rName.value = v; + lastLoginVal = v; + }); + loginForm.addEventListener('submit', async (e) => { e.preventDefault(); showMsg(''); @@ -170,16 +205,19 @@ regForm.addEventListener('submit', async (e) => { e.preventDefault(); showMsg(''); - const payload = { - name: document.getElementById('rName').value.trim(), - login: document.getElementById('rLogin').value.trim(), - email: document.getElementById('rEmail').value.trim(), - password: document.getElementById('rPwd').value, - }; - if (!payload.login || !payload.password){ showMsg('账号和密码必填', 'err'); return; } + const login = document.getElementById('rLogin').value.trim(); + const name = document.getElementById('rName').value.trim(); + const password = document.getElementById('rPwd').value; + const pwd2 = document.getElementById('rPwd2').value; + if (!login) { showMsg('请填写账号', 'err'); return; } + if (password.length < 8) { showMsg('密码至少 8 位', 'err'); return; } + if (!/[a-zA-Z]/.test(password) || !/\d/.test(password)) { + showMsg('密码必须同时包含字母和数字', 'err'); return; + } + if (password !== pwd2) { showMsg('两次密码输入不一致', 'err'); return; } const btn = regForm.querySelector('.submit'); btn.disabled = true; btn.textContent = '注册中…'; try { - const r = await API.register(payload); + const r = await API.register({ name, login, password }); if (r.ok){ location.href = nextUrl; } else { showMsg(r.error || '注册失败', 'err'); } } catch (err) { diff --git a/world.html b/world.html index 4d18dbd..b0d9eb9 100644 --- a/world.html +++ b/world.html @@ -347,9 +347,22 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa transition:opacity .2s ease,transform .2s ease;} #worldNav .wn-item:hover .wn-text{opacity:1;transform:translateY(-50%) translateX(0);} +/* ===== 登录闸门 ===== */ +#authSplash{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center; + background:linear-gradient(160deg,#080c1a,#0e1730 48%,#15213f);color:#9aa6c4; + font-family:"Noto Sans SC",system-ui,sans-serif;font-size:15px;letter-spacing:3px;} +#authBar{position:fixed;top:8px;right:16px;z-index:110;display:none;align-items:center;gap:8px; + padding:5px 12px;border-radius:999px;background:rgba(10,16,30,.85);backdrop-filter:blur(10px); + border:1px solid rgba(255,255,255,.14);font-family:"Noto Sans SC",system-ui,sans-serif;font-size:12px;color:#e9edf6;} +#authBar .ab-name{color:#c77dff;font-weight:500;} +#authBar .ab-logout{cursor:pointer;color:#9aa6c4;border:1px solid rgba(255,255,255,.18);background:transparent;border-radius:8px;padding:4px 10px;font-family:inherit;font-size:12px;transition:.2s;} +#authBar .ab-logout:hover{color:#fff;border-color:#8b5cf6;} + +
登录校验中…
+
@@ -3107,6 +3120,27 @@ loadSavedWorld(); // 有存档则恢复玩家建造结果 init(); +