官网首页:去掉 YUSEN 小标、右上角登录/注册、进入游戏走登录闸、补 favicon;补充 game_base 菜单图标与 yt_world 沙盘记忆模块

This commit is contained in:
李鹏宇 2026-07-23 17:24:21 +08:00
parent 63449893ca
commit 3d34fbc7cc
16 changed files with 456 additions and 22 deletions

View File

@ -1,8 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 顶层菜单 ============ -->
<menuitem id="menu_game_root" name="宇森游戏" sequence="10"/>
<!-- ============ 顶层菜单(带品牌 logo ============ -->
<menuitem id="menu_game_root" name="宇森游戏" sequence="10"
web_icon="game_base,static/description/icon.png"/>
<!-- ============ 图鉴(按分类 + 子级分组) ============ -->
<record id="action_game_codex" model="ir.actions.act_window">

View File

@ -0,0 +1,2 @@
from . import models
from . import controllers

View File

@ -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',
}

View File

@ -0,0 +1 @@
from . import world_api

View File

@ -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})

View File

@ -0,0 +1,4 @@
from . import yt_world
from . import yt_world_tile
from . import yt_world_tile_micro
from . import yt_resource

View File

@ -0,0 +1,15 @@
TERRAIN_SELECTION = [
('water', '水域'),
('desert', '荒漠'),
('plain', '平原'),
('forest', '森林'),
('mountain', '山脉'),
('snow', '雪地'),
]
RESOURCE_TYPE_SELECTION = [
('wood', '木材'),
('food', '食物'),
('stone', '石料'),
('fish', '鱼获'),
]

View File

@ -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)',
'同地块同类型资源唯一'),
]

View File

@ -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()

View File

@ -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)

View File

@ -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)',
'一个地块只能有一个微观世界(一对一)'),
]

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_yt_world yt.world model_yt_world base.group_user 1 1 1 1
3 access_yt_world_tile yt.world.tile model_yt_world_tile base.group_user 1 1 1 1
4 access_yt_world_tile_micro yt.world.tile.micro model_yt_world_tile_micro base.group_user 1 1 1 1
5 access_yt_resource yt.resource model_yt_resource base.group_user 1 1 1 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View File

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 顶层菜单(带品牌 logo直接打开玩家世界列表 ============ -->
<menuitem id="menu_yt_world_root"
name="宇森世界"
sequence="20"
action="action_yt_world"
web_icon="yt_world,static/description/icon.png"/>
<!-- ============ 玩家世界 ============ -->
<record id="action_yt_world" model="ir.actions.act_window">
<field name="name">玩家世界</field>
<field name="res_model">yt.world</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_yt_world_list" model="ir.ui.view">
<field name="name">yt.world.list</field>
<field name="model">yt.world</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="user_id"/>
<field name="seed"/>
<field name="world_version"/>
<field name="last_active"/>
</list>
</field>
</record>
<record id="view_yt_world_form" model="ir.ui.view">
<field name="name">yt.world.form</field>
<field name="model">yt.world</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="name"/>
<field name="user_id"/>
<field name="seed"/>
<field name="world_version"/>
<field name="settings" widget="json"/>
<field name="last_active"/>
</group>
<notebook>
<page string="地表地块">
<field name="tile_ids">
<list>
<field name="q"/>
<field name="r"/>
<field name="terrain"/>
<field name="height"/>
<field name="micro_world_id"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- ============ 地表地块 ============ -->
<menuitem id="menu_yt_world_tile" name="地表地块"
parent="menu_yt_world_root" action="action_yt_world_tile" sequence="10"/>
<record id="action_yt_world_tile" model="ir.actions.act_window">
<field name="name">地表地块</field>
<field name="res_model">yt.world.tile</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_yt_world_tile_list" model="ir.ui.view">
<field name="name">yt.world.tile.list</field>
<field name="model">yt.world.tile</field>
<field name="arch" type="xml">
<list>
<field name="world_id"/>
<field name="q"/>
<field name="r"/>
<field name="terrain"/>
<field name="height"/>
<field name="micro_world_id"/>
<field name="updated_at"/>
</list>
</field>
</record>
<record id="view_yt_world_tile_form" model="ir.ui.view">
<field name="name">yt.world.tile.form</field>
<field name="model">yt.world.tile</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<field name="world_id"/>
<field name="q"/>
<field name="r"/>
<field name="terrain"/>
<field name="height"/>
<field name="flags" widget="json"/>
<field name="micro_world_id"/>
<field name="updated_at"/>
</group>
</sheet>
</form>
</field>
</record>
</odoo>

View File

@ -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();
},
};

View File

@ -7,6 +7,9 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&family=ZCOOL+QingKe+HuangYou&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
<link rel="icon" type="image/png" sizes="48x48" href="assets/ui/logo_planet_48.png?v=20260723">
<link rel="icon" type="image/png" sizes="256x256" href="assets/ui/logo_planet_256.png?v=20260723">
<link rel="apple-touch-icon" href="assets/ui/logo_planet_256.png?v=20260723">
<style>
:root{
--bg-0:#080c1a; --bg-1:#0e1730; --bg-2:#15213f;
@ -45,6 +48,10 @@
.user-box .ub-name{color:#fff;font-weight:500;}
.user-box .ub-logout{font-family:inherit;font-size:12px;color:var(--text-dim);background:rgba(255,255,255,.06);border:1px solid var(--card-bd);padding:5px 12px;border-radius:8px;cursor:pointer;transition:.2s;}
.user-box .ub-logout:hover{color:#fff;background:rgba(139,92,246,.18);}
.user-box .auth-link{color:var(--text-dim);text-decoration:none;font-size:14px;transition:.2s;padding:5px 8px;}
.user-box .auth-link:hover{color:#fff;}
.user-box .auth-btn{font-family:inherit;font-size:13px;color:#0b1020;background:linear-gradient(120deg,var(--gold),#f0d49a);border:none;padding:6px 16px;border-radius:20px;cursor:pointer;font-weight:600;transition:.2s;}
.user-box .auth-btn:hover{filter:brightness(1.1);box-shadow:0 0 16px rgba(231,184,92,.4);}
.cta:hover{filter:brightness(1.08);}
.content{flex:1;position:relative;overflow:hidden;}
@ -178,7 +185,7 @@
<body>
<div class="app">
<header class="topbar">
<div class="logo"><span class="cn">宇森</span><span class="en">YUSEN</span></div>
<div class="logo"><span class="cn">宇森</span></div>
<nav class="nav">
<button data-tab="intro" class="active">介绍</button>
<button data-tab="codex">图鉴</button>
@ -540,31 +547,44 @@
// ============ 登录态 / 右上角个人信息(从 Odoo 取) ============
(function(){
const userBox = document.getElementById('userBox');
async function refreshUserBox(){
let cachedMe = null;
function renderLoggedOut(){
if(!userBox) return;
userBox.innerHTML = '<a href="login.html?next=world.html" class="auth-link">登录</a><a href="login.html?next=world.html" class="auth-btn">注册</a>';
}
function renderLoggedIn(me){
if(!userBox) return;
userBox.innerHTML = '<span class="ub-name"></span><button class="ub-logout" id="ubLogout">退出</button>';
const nm = userBox.querySelector('.ub-name');
if(nm) nm.textContent = me.name || me.login;
const lb = document.getElementById('ubLogout');
if(lb) lb.addEventListener('click', async ()=>{
try { await API.logout(); } catch(e){}
location.href = 'index.html';
});
}
async function refreshUserBox(){
try{
const me = await API.me();
if(me && me.uid){
userBox.innerHTML = '<span class="ub-name"></span><button class="ub-logout" id="ubLogout">退出</button>';
const nm = userBox.querySelector('.ub-name');
if(nm) nm.textContent = me.name || me.login;
const lb = document.getElementById('ubLogout');
if(lb) lb.addEventListener('click', async ()=>{
await API.logout();
location.href = 'index.html';
});
} else {
userBox.innerHTML = ''; // 未登录:右上角不显示登录入口,登录经“进入游戏”引导
}
}catch(e){ userBox.innerHTML = ''; }
cachedMe = await API.me();
if(cachedMe && cachedMe.uid) renderLoggedIn(cachedMe);
else renderLoggedOut();
}catch(e){ renderLoggedOut(); }
}
refreshUserBox();
// ============ 进入游戏:已登录→世界页;未登录→提示登录 ============
// TEMP: 本地临时默认直接进入游戏,跳过 Odoo 登录校验;打通后端后改回 API.me() 判断
// ============ 进入游戏:已登录→世界页;未登录→登录页 ============
const enterBtn = document.getElementById('enterGame');
if(enterBtn) enterBtn.addEventListener('click', ()=>{
location.href = 'world.html';
if(enterBtn) enterBtn.addEventListener('click', async ()=>{
try {
const me = cachedMe || await API.me();
if (me && me.uid) location.href = 'world.html';
else location.href = 'login.html?next=world.html';
} catch(e) {
location.href = 'login.html?next=world.html';
}
});
})();
</script>