Merge remote-tracking branch 'origin/main'

This commit is contained in:
李鹏宇 2026-07-23 18:57:05 +08:00
commit a00ea237eb
22 changed files with 678 additions and 55 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

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

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

View File

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

View File

@ -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>
<input id="lPwd" type="password" placeholder="密码" />
<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) {

View File

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