Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
a00ea237eb
@ -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})
|
||||
|
||||
BIN
addons/game_base/static/description/icon.png
Normal file
BIN
addons/game_base/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
112
addons/game_base/static/description/index.html
Normal file
112
addons/game_base/static/description/index.html
Normal file
@ -0,0 +1,112 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Yusen Game Base</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
padding: 24px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.hero img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
image-rendering: pixelated;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.12);
|
||||
}
|
||||
.hero h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 24px;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.hero p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.section {
|
||||
margin-top: 18px;
|
||||
}
|
||||
.section h2 {
|
||||
font-size: 16px;
|
||||
margin: 0 0 8px;
|
||||
color: #2c3e50;
|
||||
border-bottom: 2px solid #4fc3f7;
|
||||
padding-bottom: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
.section ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
}
|
||||
.section li {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
code {
|
||||
background: #f4f6f8;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 13px;
|
||||
color: #c0392b;
|
||||
}
|
||||
.tag {
|
||||
display: inline-block;
|
||||
background: #e3f2fd;
|
||||
color: #0277bd;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="hero">
|
||||
<img src="logo.png" alt="Yusen Game Base logo">
|
||||
<div>
|
||||
<h1>Yusen Game Base</h1>
|
||||
<p><span class="tag">Game</span> 宇森沙盘游戏后台基础模块</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>沉淀游戏世界观内容,为前端 <code>/game/api/*</code> 提供只读数据;同时打通 Odoo <code>res.users</code>,提供玩家登录/注册能力。</p>
|
||||
|
||||
<div class="section">
|
||||
<h2>数据模型</h2>
|
||||
<ul>
|
||||
<li><strong>图鉴 (Codex)</strong> — 怪物、物品、地形等条目,支持分类与富文本。</li>
|
||||
<li><strong>规则 (Rule)</strong> — 游戏规则与设定说明,版本化管理。</li>
|
||||
<li><strong>论坛 (Forum)</strong> — 帖子与回复,沉淀玩家讨论。</li>
|
||||
<li><strong>公告 (Announcement)</strong> — 游戏运营公告与更新日志。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>前端 API</h2>
|
||||
<ul>
|
||||
<li><code>GET /game/api/codex?db=game</code></li>
|
||||
<li><code>GET /game/api/rules?db=game</code></li>
|
||||
<li><code>GET /game/api/threads?db=game</code></li>
|
||||
<li><code>GET /game/api/announcements?db=game</code></li>
|
||||
<li><code>POST /game/api/login?db=game</code></li>
|
||||
<li><code>POST /game/api/register?db=game</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>依赖</h2>
|
||||
<p>仅依赖 <code>base</code>,安装简单,无额外第三方模块。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
addons/game_base/static/description/logo.png
Normal file
BIN
addons/game_base/static/description/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 229 KiB |
@ -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">
|
||||
|
||||
2
addons/yt_world/__init__.py
Normal file
2
addons/yt_world/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
14
addons/yt_world/__manifest__.py
Normal file
14
addons/yt_world/__manifest__.py
Normal 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',
|
||||
}
|
||||
1
addons/yt_world/controllers/__init__.py
Normal file
1
addons/yt_world/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import world_api
|
||||
61
addons/yt_world/controllers/world_api.py
Normal file
61
addons/yt_world/controllers/world_api.py
Normal 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})
|
||||
4
addons/yt_world/models/__init__.py
Normal file
4
addons/yt_world/models/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
from . import yt_world
|
||||
from . import yt_world_tile
|
||||
from . import yt_world_tile_micro
|
||||
from . import yt_resource
|
||||
15
addons/yt_world/models/constants.py
Normal file
15
addons/yt_world/models/constants.py
Normal file
@ -0,0 +1,15 @@
|
||||
TERRAIN_SELECTION = [
|
||||
('water', '水域'),
|
||||
('desert', '荒漠'),
|
||||
('plain', '平原'),
|
||||
('forest', '森林'),
|
||||
('mountain', '山脉'),
|
||||
('snow', '雪地'),
|
||||
]
|
||||
|
||||
RESOURCE_TYPE_SELECTION = [
|
||||
('wood', '木材'),
|
||||
('food', '食物'),
|
||||
('stone', '石料'),
|
||||
('fish', '鱼获'),
|
||||
]
|
||||
20
addons/yt_world/models/yt_resource.py
Normal file
20
addons/yt_world/models/yt_resource.py
Normal 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)',
|
||||
'同地块同类型资源唯一'),
|
||||
]
|
||||
88
addons/yt_world/models/yt_world.py
Normal file
88
addons/yt_world/models/yt_world.py
Normal 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()
|
||||
52
addons/yt_world/models/yt_world_tile.py
Normal file
52
addons/yt_world/models/yt_world_tile.py
Normal 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)
|
||||
22
addons/yt_world/models/yt_world_tile_micro.py
Normal file
22
addons/yt_world/models/yt_world_tile_micro.py
Normal 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)',
|
||||
'一个地块只能有一个微观世界(一对一)'),
|
||||
]
|
||||
5
addons/yt_world/security/ir.model.access.csv
Normal file
5
addons/yt_world/security/ir.model.access.csv
Normal 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
|
||||
|
BIN
addons/yt_world/static/description/icon.png
Normal file
BIN
addons/yt_world/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 89 KiB |
111
addons/yt_world/views/yt_world_views.xml
Normal file
111
addons/yt_world/views/yt_world_views.xml
Normal 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>
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
55
index.html
55
index.html
@ -4,9 +4,12 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>宇森 · Yusen|活的文明系统模拟</title>
|
||||
<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="preconnect" href="https://fonts.googleapis.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" media="print" onload="this.media='all'" />
|
||||
<noscript><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" /></noscript>
|
||||
<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;}
|
||||
@ -191,7 +198,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="features">特色</button>
|
||||
@ -210,7 +217,7 @@
|
||||
<!-- ===== 介绍(视频主页) ===== -->
|
||||
<section class="panel active" id="intro">
|
||||
<div class="hero">
|
||||
<video autoplay muted loop playsinline>
|
||||
<video autoplay muted loop playsinline preload="metadata" poster="assets/bg/bg_sky.jpg">
|
||||
<source src="assets/yusen-trailer.mp4" type="video/mp4" />
|
||||
</video>
|
||||
<div class="veil"></div>
|
||||
@ -360,7 +367,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="codex-data.js"></script>
|
||||
<script>
|
||||
// top nav
|
||||
const navBtns=document.querySelectorAll('.nav button');
|
||||
@ -576,31 +582,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;
|
||||
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();
|
||||
try { await API.logout(); } catch(e){}
|
||||
location.href = 'index.html';
|
||||
});
|
||||
} else {
|
||||
userBox.innerHTML = ''; // 未登录:右上角不显示登录入口,登录经“进入游戏”引导
|
||||
}
|
||||
}catch(e){ userBox.innerHTML = ''; }
|
||||
|
||||
async function refreshUserBox(){
|
||||
try{
|
||||
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>
|
||||
|
||||
88
login.html
88
login.html
@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>宇森 · 登录</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=ZCOOL+QingKe+HuangYou&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||
<style>
|
||||
:root{
|
||||
--bg-0:#080c1a; --bg-1:#0e1730; --bg-2:#15213f;
|
||||
@ -16,7 +15,7 @@
|
||||
*{box-sizing:border-box;margin:0;padding:0;}
|
||||
html,body{height:100%;}
|
||||
body{
|
||||
font-family:"Noto Sans SC",system-ui,sans-serif;color:var(--text);
|
||||
font-family:"PingFang SC","Hiragino Sans GB","Microsoft YaHei","Noto Sans CJK SC",system-ui,-apple-system,sans-serif;color:var(--text);
|
||||
background:
|
||||
radial-gradient(1000px 620px at 82% -8%, rgba(139,92,246,.16), transparent 60%),
|
||||
radial-gradient(820px 560px at 8% 112%, rgba(76,201,240,.12), transparent 55%),
|
||||
@ -31,7 +30,7 @@
|
||||
|
||||
.wrap{position:relative;z-index:2;width:min(420px,92vw);}
|
||||
.brand{text-align:center;margin-bottom:26px;}
|
||||
.brand .cn{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:38px;letter-spacing:6px;color:#fff;text-shadow:0 0 18px rgba(139,92,246,.6);}
|
||||
.brand .cn{font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;font-weight:900;font-size:42px;letter-spacing:8px;color:#fff;text-shadow:0 0 18px rgba(139,92,246,.6);}
|
||||
.brand .en{font-size:11px;color:var(--magenta);letter-spacing:3px;margin-top:6px;}
|
||||
.brand .tag{font-size:13px;color:var(--text-dim);margin-top:10px;}
|
||||
|
||||
@ -44,6 +43,10 @@
|
||||
.field label{display:block;font-size:12px;color:var(--text-dim);margin-bottom:6px;letter-spacing:1px;}
|
||||
.field input{width:100%;background:rgba(8,12,26,.6);border:1px solid var(--card-bd);border-radius:10px;color:var(--text);padding:11px 13px;font-family:inherit;font-size:14px;outline:none;transition:.2s;}
|
||||
.field input:focus{border-color:var(--violet);box-shadow:0 0 0 3px rgba(139,92,246,.18);}
|
||||
.pw-wrap{position:relative;}
|
||||
.pw-wrap input{padding-right:40px;}
|
||||
.pw-toggle{position:absolute;right:8px;top:50%;transform:translateY(-50%);background:transparent;border:none;color:var(--text-dim);cursor:pointer;font-size:16px;line-height:1;padding:4px 6px;border-radius:6px;transition:.2s;}
|
||||
.pw-toggle:hover{color:#fff;background:rgba(255,255,255,.08);}
|
||||
|
||||
.submit{width:100%;margin-top:6px;font-size:15px;font-weight:700;color:#0b1020;background:linear-gradient(120deg,var(--gold),#f0d49a);padding:12px;border-radius:11px;cursor:pointer;border:none;letter-spacing:2px;transition:.22s;box-shadow:0 0 18px rgba(231,184,92,.35);}
|
||||
.submit:hover{filter:brightness(1.08);}
|
||||
@ -57,8 +60,6 @@
|
||||
.back a{color:var(--cyan);text-decoration:none;}
|
||||
.back a:hover{text-decoration:underline;}
|
||||
|
||||
.hint{margin-bottom:16px;font-size:13px;color:var(--cyan);background:rgba(76,201,240,.10);border:1px solid rgba(76,201,240,.30);border-radius:10px;padding:9px 12px;text-align:center;}
|
||||
|
||||
.hidden{display:none;}
|
||||
</style>
|
||||
</head>
|
||||
@ -71,7 +72,6 @@
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="hint hidden" id="hint">登录后即可进入游戏</div>
|
||||
<div class="tabs">
|
||||
<button id="tabLogin" class="active">登录</button>
|
||||
<button id="tabReg">注册</button>
|
||||
@ -81,32 +81,41 @@
|
||||
<form id="loginForm" autocomplete="off">
|
||||
<div class="field">
|
||||
<label>账号</label>
|
||||
<input id="lLogin" type="text" placeholder="Odoo 用户名 / 邮箱" />
|
||||
<input id="lLogin" type="text" placeholder="Odoo 用户名" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>密码</label>
|
||||
<div class="pw-wrap">
|
||||
<input id="lPwd" type="password" placeholder="密码" />
|
||||
<button type="button" class="pw-toggle" data-target="lPwd" aria-label="显示密码">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="submit" type="submit">进入世界</button>
|
||||
</form>
|
||||
|
||||
<!-- 注册 -->
|
||||
<form id="regForm" class="hidden" autocomplete="off">
|
||||
<div class="field">
|
||||
<label>昵称</label>
|
||||
<input id="rName" type="text" placeholder="显示名称(可空)" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>账号</label>
|
||||
<input id="rLogin" type="text" placeholder="登录用账号" />
|
||||
<input id="rLogin" type="text" placeholder="登录用账号(字母/数字/._-)" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>邮箱</label>
|
||||
<input id="rEmail" type="email" placeholder="邮箱(可空)" />
|
||||
<label>昵称</label>
|
||||
<input id="rName" type="text" placeholder="默认与账号一致,可改" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>密码</label>
|
||||
<input id="rPwd" type="password" placeholder="至少 6 位" />
|
||||
<div class="pw-wrap">
|
||||
<input id="rPwd" type="password" placeholder="至少 8 位,字母+数字" />
|
||||
<button type="button" class="pw-toggle" data-target="rPwd" aria-label="显示密码">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>确认密码</label>
|
||||
<div class="pw-wrap">
|
||||
<input id="rPwd2" type="password" placeholder="再次输入密码" />
|
||||
<button type="button" class="pw-toggle" data-target="rPwd2" aria-label="显示密码">👁</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="submit" type="submit">创建园丁账号</button>
|
||||
</form>
|
||||
@ -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) {
|
||||
|
||||
34
world.html
34
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;}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="authSplash">登录校验中…</div>
|
||||
<div id="authBar"><span class="ab-name"></span><button class="ab-logout" type="button">退出</button></div>
|
||||
|
||||
<!-- 加载界面 -->
|
||||
<div id="loadingScreen">
|
||||
@ -3107,6 +3120,27 @@ loadSavedWorld(); // 有存档则恢复玩家建造结果
|
||||
init();
|
||||
</script>
|
||||
<script src="assets/game-api.js"></script>
|
||||
<script>
|
||||
// ===== 登录闸门:未登录跳登录页;已登录显示昵称+退出 =====
|
||||
(async () => {
|
||||
const splash = document.getElementById('authSplash');
|
||||
const bar = document.getElementById('authBar');
|
||||
try {
|
||||
const me = await API.me();
|
||||
if (!me || !me.uid) { location.replace('login.html?next=world.html'); return; }
|
||||
bar.querySelector('.ab-name').textContent = me.name || me.login;
|
||||
bar.style.display = 'flex';
|
||||
bar.querySelector('.ab-logout').onclick = async () => {
|
||||
try { await API.logout(); } catch (e) {}
|
||||
location.replace('login.html');
|
||||
};
|
||||
} catch (e) {
|
||||
location.replace('login.html?next=world.html'); return;
|
||||
} finally {
|
||||
if (splash) splash.remove();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="assets/world-app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user