diff --git a/addons/game_base/__manifest__.py b/addons/game_base/__manifest__.py
index 8fd8aba..9dfbebb 100644
--- a/addons/game_base/__manifest__.py
+++ b/addons/game_base/__manifest__.py
@@ -1,13 +1,14 @@
{
'name': '宇森游戏基础数据',
- 'summary': '宇森游戏基础数据:图鉴 / 规则 / 论坛 / 公告',
- 'version': '1.2',
+ 'summary': '宇森游戏基础数据:图鉴 / 规则 / 论坛 / 公告 / 玩家员工',
+ 'version': '1.3',
'category': 'Game',
'sequence': 10,
- 'depends': ['base'],
+ 'depends': ['base', 'hr'],
'data': [
'security/ir.model.access.csv',
'data/game_base_data.xml',
+ 'data/game_achievement_data.xml',
'views/codex_views.xml',
'views/rule_category_views.xml',
'views/rule_views.xml',
@@ -15,10 +16,13 @@
'views/forum_views.xml',
'views/announcement_views.xml',
'views/story_views.xml',
+ 'views/game_employee_views.xml',
+ 'views/game_achievement_views.xml',
+ 'views/game_mail_views.xml',
'views/menu.xml',
],
'application': True,
'license': 'LGPL-3',
'pre_init_hook': 'pre_init_hook',
- 'description': '基于官网内容沉淀的游戏基础数据模型,供后台维护、前端经 /game/api/* 读取;并提供与 Odoo 用户打通的登录/注册接口。',
+ 'description': '基于官网内容沉淀的游戏基础数据模型,供后台维护、前端经 /game/api/* 读取;并提供与 Odoo 用户打通的登录/注册接口。注册时自动创建 hr.employee 并初始化游戏数据。',
}
diff --git a/addons/game_base/controllers/game_api.py b/addons/game_base/controllers/game_api.py
index db230ca..452d637 100644
--- a/addons/game_base/controllers/game_api.py
+++ b/addons/game_base/controllers/game_api.py
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
import json
-from odoo import http
+from odoo import http, fields
from odoo.http import request, Response
@@ -309,12 +309,52 @@ class GameApiController(http.Controller):
'groups_id': [(6, 0, [portal.id])],
})
request.env.cr.commit() # 让新用户立即对其他游标可见,便于随后自动登录
+ # === 自动创建 hr.employee 并初始化游戏数据 ===
+ Emp = request.env['hr.employee'].sudo()
+ emp = Emp.create({
+ 'name': name or login,
+ 'user_id': user.id,
+ 'work_email': email or False,
+ 'game_title': '萌新',
+ 'game_level': 1,
+ 'game_exp': 0,
+ 'game_exp_max': 100,
+ 'game_coins': 0,
+ 'game_energy': 0,
+ 'game_wood': 0,
+ 'game_stone': 0,
+ 'game_reg_date': fields.Date.today(),
+ })
+ # 生成游戏 UID(格式 10086 + 4位员工ID)
+ emp.game_uid = '10086%04d' % emp.id
+ # 给新玩家解锁第一个成就"开天辟地"
+ tmpl = request.env['game.achievement.template'].sudo().search(
+ [('code', '=', 'first_land')], limit=1)
+ if tmpl:
+ request.env['game.player.achievement'].sudo().create({
+ 'employee_id': emp.id,
+ 'template_id': tmpl.id,
+ 'progress': tmpl.target,
+ 'unlocked': True,
+ 'unlock_date': fields.Datetime.now(),
+ })
+ # 给新玩家发一封欢迎邮件
+ request.env['game.player.mail'].sudo().create({
+ 'employee_id': emp.id,
+ 'category': 'system',
+ 'subject': '欢迎来到宇森',
+ 'sender': '系统',
+ 'body': '
欢迎来到宇森!请查收新手礼包与世界探索指南。
',
+ })
+ request.env.cr.commit()
db = request.session.db or 'game'
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})
+ return _json({'ok': True, 'uid': uid, 'name': user.name, 'login': user.login,
+ 'emp_id': emp.id, 'game_uid': emp.game_uid})
+ return _json({'ok': True, 'uid': user.id, 'name': user.name, 'login': user.login,
+ 'emp_id': emp.id, 'game_uid': emp.game_uid})
except Exception as e:
return _json({'ok': False, 'error': '注册失败:%s' % str(e)})
@@ -324,9 +364,123 @@ class GameApiController(http.Controller):
if uid:
user = request.env['res.users'].sudo().browse(uid)
if user.exists():
- return _json({'uid': uid, 'name': user.name, 'login': user.login})
+ data = {'uid': uid, 'name': user.name, 'login': user.login,
+ 'email': user.email or ''}
+ emp = request.env['hr.employee'].sudo().search(
+ [('user_id', '=', uid)], limit=1)
+ if emp:
+ data.update(emp.export_game_data())
+ return _json(data)
return _json({'uid': None})
+ @http.route('/game/api/achievements', type='http', auth='public',
+ methods=['GET'], csrf=False)
+ def achievements(self, **kw):
+ """返回当前登录玩家的成就列表(含模板+进度)。"""
+ uid = request.session.uid
+ if not uid:
+ return _json({'error': '未登录'})
+ emp = request.env['hr.employee'].sudo().search(
+ [('user_id', '=', uid)], limit=1)
+ if not emp:
+ return _json({'error': '玩家不存在', 'items': [], 'total_points': 0})
+ # 取所有启用的成就模板
+ tmpl_model = request.env['game.achievement.template'].sudo()
+ tier_labels = dict(tmpl_model._fields['tier'].selection)
+ templates = tmpl_model.search_read(
+ [('active', '=', True)],
+ ['id', 'name', 'icon', 'tier', 'desc', 'points', 'target', 'sequence'],
+ order='sequence, id')
+ # 取该玩家已解锁的成就
+ player_recs = request.env['game.player.achievement'].sudo().search_read(
+ [('employee_id', '=', emp.id)],
+ ['template_id', 'progress', 'unlocked', 'unlock_date'])
+ progress_map = {
+ r['template_id'][0]: r for r in player_recs
+ }
+ items = []
+ total_points = 0
+ unlocked_count = 0
+ for t in templates:
+ pr = progress_map.get(t['id'])
+ unlocked = pr and pr['unlocked']
+ progress = pr['progress'] if pr else 0
+ if unlocked:
+ total_points += t['points']
+ unlocked_count += 1
+ items.append({
+ 'id': t['id'],
+ 'name': t['name'],
+ 'icon': t.get('icon') or '',
+ 'tier': t['tier'],
+ 'tier_label': tier_labels.get(t['tier'], t['tier']),
+ 'desc': t.get('desc') or '',
+ 'points': t['points'],
+ 'target': t['target'],
+ 'progress': progress,
+ 'unlocked': bool(unlocked),
+ 'unlock_date': str(pr['unlock_date']) if pr and pr['unlock_date'] else None,
+ })
+ return _json({
+ 'items': items,
+ 'total_points': total_points,
+ 'unlocked_count': unlocked_count,
+ 'total_count': len(items),
+ })
+
+ @http.route('/game/api/mails', type='http', auth='public',
+ methods=['GET'], csrf=False)
+ def mails(self, **kw):
+ """返回当前登录玩家的邮件列表。"""
+ uid = request.session.uid
+ if not uid:
+ return _json({'error': '未登录', 'items': []})
+ emp = request.env['hr.employee'].sudo().search(
+ [('user_id', '=', uid)], limit=1)
+ if not emp:
+ return _json({'error': '玩家不存在', 'items': []})
+ cat_labels = dict(
+ request.env['game.player.mail'].sudo()._fields['category'].selection)
+ recs = request.env['game.player.mail'].sudo().search_read(
+ [('employee_id', '=', emp.id)],
+ ['id', 'category', 'subject', 'body', 'date', 'is_read',
+ 'is_pinned', 'sender'],
+ order='is_pinned desc, date desc')
+ items = [{
+ 'id': r['id'],
+ 'category': r['category'],
+ 'category_label': cat_labels.get(r['category'], r['category']),
+ 'subject': r['subject'],
+ 'body': r.get('body') or '',
+ 'date': str(r['date']) if r['date'] else '',
+ 'is_read': r['is_read'],
+ 'is_pinned': r['is_pinned'],
+ 'sender': r.get('sender') or '',
+ } for r in recs]
+ unread = len([r for r in items if not r['is_read']])
+ return _json({'items': items, 'unread': unread})
+
+ @http.route('/game/api/mail/read', type='http', auth='public',
+ methods=['POST', 'OPTIONS'], csrf=False)
+ def mail_read(self, **kw):
+ """标记邮件为已读。"""
+ uid = request.session.uid
+ if not uid:
+ return _json({'ok': False, 'error': '未登录'})
+ data = _post_body()
+ mail_id = data.get('mail_id')
+ if not mail_id:
+ return _json({'ok': False, 'error': '缺少 mail_id'})
+ emp = request.env['hr.employee'].sudo().search(
+ [('user_id', '=', uid)], limit=1)
+ if not emp:
+ return _json({'ok': False, 'error': '玩家不存在'})
+ mail = request.env['game.player.mail'].sudo().browse(mail_id)
+ if not mail.exists() or mail.employee_id != emp:
+ return _json({'ok': False, 'error': '邮件不存在或无权操作'})
+ mail.write({'is_read': True})
+ return _json({'ok': True})
+
@http.route('/game/api/logout', type='http', auth='public',
methods=['POST', 'OPTIONS'], csrf=False)
def logout(self, **kw):
diff --git a/addons/game_base/data/game_achievement_data.xml b/addons/game_base/data/game_achievement_data.xml
new file mode 100644
index 0000000..b1f7683
--- /dev/null
+++ b/addons/game_base/data/game_achievement_data.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+ first_land
+ 10
+ 开天辟地
+ star
+ gold
+ 50
+ 1
+ 已点亮第一块领土,文明之火开始燃烧。
+
+
+
+ civilization_cradle
+ 20
+ 文明摇篮
+ sprout
+ silver
+ 30
+ 5
+ 建立 5 个聚落,文明初步成形。
+
+
+
+ sky_tower
+ 30
+ 通天塔
+ lock
+ platinum
+ 100
+ 10
+ 建造 10 座奇观,文明疆域遍布穹苍。
+
+
+
+ world_explorer
+ 40
+ 世界探索者
+ compass
+ bronze
+ 10
+ 10
+ 探索 10 块未知领土。
+
+
+
+ resource_master
+ 50
+ 资源大亨
+ gem
+ silver
+ 30
+ 1000
+ 累计获得 1000 金币。
+
+
+
+ spirit_awakening
+ 60
+ 灵气觉醒
+ sparkle
+ gold
+ 50
+ 1
+ 世界阶段跃迁至「灵启」。
+
+
+
+ mystic_realm
+ 70
+ 玄界之主
+ crown
+ legend
+ 200
+ 1
+ 世界阶段跃迁至「玄界」。
+
+
+
+ eternal
+ 80
+ 永恒之锚
+ infinity
+ legend
+ 500
+ 1
+ 世界阶段达到「溯元」,触及世界本源。
+
+
+
+
diff --git a/addons/game_base/models/__init__.py b/addons/game_base/models/__init__.py
index 634af88..1297619 100644
--- a/addons/game_base/models/__init__.py
+++ b/addons/game_base/models/__init__.py
@@ -6,3 +6,6 @@ from . import rule
from . import page
from . import story
from . import ir_http
+from . import game_employee
+from . import game_achievement
+from . import game_mail
diff --git a/addons/game_base/models/game_achievement.py b/addons/game_base/models/game_achievement.py
new file mode 100644
index 0000000..588d4f7
--- /dev/null
+++ b/addons/game_base/models/game_achievement.py
@@ -0,0 +1,85 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models
+
+
+class GameAchievementTemplate(models.Model):
+ """全局成就模板:后台定义,所有玩家共用同一套成就定义。"""
+ _name = 'game.achievement.template'
+ _description = '成就模板'
+ _order = 'sequence, id'
+ _rec_name = 'name'
+
+ TIER = [
+ ('bronze', '铜'),
+ ('silver', '银'),
+ ('gold', '金'),
+ ('platinum', '铂金'),
+ ('legend', '传奇'),
+ ]
+
+ sequence = fields.Integer('排序', default=10, index=True)
+ code = fields.Char('标识', index=True,
+ help='唯一标识,用于代码引用,如 first_land')
+ name = fields.Char('成就名称', required=True, translate=True)
+ icon = fields.Char('图标', help='emoji 或符号,如 star / sprout / lock')
+ tier = fields.Selection(TIER, '品质', default='bronze', index=True)
+ desc = fields.Text('描述', help='解锁条件/说明')
+ target = fields.Integer('目标值', default=1,
+ help='解锁所需进度值,如 10 表示需完成 10 次')
+ points = fields.Integer('成就点数', default=10)
+ active = fields.Boolean('启用', default=True)
+ player_achievement_ids = fields.One2many(
+ 'game.player.achievement', 'template_id', '玩家解锁记录')
+
+
+class GamePlayerAchievement(models.Model):
+ """玩家已解锁/进行中的成就,关联到 hr.employee。"""
+ _name = 'game.player.achievement'
+ _description = '玩家成就'
+ _order = 'template_id, id'
+ _rec_name = 'name'
+
+ template_id = fields.Many2one(
+ 'game.achievement.template', '成就模板',
+ required=True, ondelete='cascade', index=True)
+ employee_id = fields.Many2one(
+ 'hr.employee', '所属玩家',
+ required=True, ondelete='cascade', index=True)
+ name = fields.Char('名称', related='template_id.name', store=True)
+ icon = fields.Char('图标', related='template_id.icon')
+ tier = fields.Selection(
+ related='template_id.tier', store=True)
+ desc = fields.Text('描述', related='template_id.desc')
+ points = fields.Integer('点数', related='template_id.points')
+ progress = fields.Integer('进度', default=0)
+ target = fields.Integer('目标值', related='template_id.target')
+ unlocked = fields.Boolean('已解锁', default=False)
+ unlock_date = fields.Datetime('解锁时间')
+
+ @api.model
+ def _update_progress(self, employee_id, template_key, progress):
+ """更新某玩家某成就的进度,达标自动解锁。"""
+ tmpl = self.env['game.achievement.template'].search(
+ [('id', '=', template_key)], limit=1)
+ if not tmpl:
+ return False
+ rec = self.search([
+ ('employee_id', '=', employee_id),
+ ('template_id', '=', tmpl.id),
+ ], limit=1)
+ if not rec:
+ rec = self.create({
+ 'employee_id': employee_id,
+ 'template_id': tmpl.id,
+ 'progress': min(progress, tmpl.target),
+ 'unlocked': progress >= tmpl.target,
+ 'unlock_date': fields.Datetime.now() if progress >= tmpl.target else False,
+ })
+ elif not rec.unlocked:
+ rec.write({
+ 'progress': min(progress, tmpl.target),
+ 'unlocked': progress >= tmpl.target,
+ })
+ if rec.unlocked:
+ rec.unlock_date = fields.Datetime.now()
+ return True
diff --git a/addons/game_base/models/game_employee.py b/addons/game_base/models/game_employee.py
new file mode 100644
index 0000000..f2c1961
--- /dev/null
+++ b/addons/game_base/models/game_employee.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models
+
+
+class GameEmployee(models.Model):
+ """在 hr.employee 上扩展游戏数据字段。"""
+ _inherit = 'hr.employee'
+
+ # === 等级系统 ===
+ game_level = fields.Integer('游戏等级', default=1)
+ game_exp = fields.Integer('经验值', default=0)
+ game_exp_max = fields.Integer('升级经验', default=100)
+ game_title = fields.Char('称号', default='萌新')
+
+ # === 资源系统 ===
+ game_coins = fields.Integer('金币', default=0)
+ game_energy = fields.Integer('灵气', default=0)
+ game_wood = fields.Integer('木材', default=0)
+ game_stone = fields.Integer('石料', default=0)
+
+ # === 世界数据 ===
+ game_world_name = fields.Char('世界名')
+ game_era = fields.Char('历元', default='星元1年·春')
+ game_tiles = fields.Integer('疆域地块', default=0)
+ game_population = fields.Integer('人口', default=0)
+ game_civ_level = fields.Integer('文明度', default=0)
+
+ # === 身份 ===
+ game_uid = fields.Char('游戏UID', index=True)
+ game_reg_date = fields.Date('注册日期', default=fields.Date.today)
+
+ # === 关联 ===
+ achievement_ids = fields.One2many('game.player.achievement', 'employee_id', '成就')
+ mail_ids = fields.One2many('game.player.mail', 'employee_id', '邮件')
+ game_achievement_count = fields.Integer(
+ '已解锁成就数', compute='_compute_achievement_count')
+ game_unread_mail = fields.Integer(
+ '未读邮件数', compute='_compute_unread_mail')
+
+ @api.depends('achievement_ids.unlocked')
+ def _compute_achievement_count(self):
+ for rec in self:
+ rec.game_achievement_count = len(
+ rec.achievement_ids.filtered('unlocked'))
+
+ @api.depends('mail_ids.is_read')
+ def _compute_unread_mail(self):
+ for rec in self:
+ rec.game_unread_mail = len(
+ rec.mail_ids.filtered(lambda m: not m.is_read))
+
+ def export_game_data(self):
+ """将游戏数据导出为前端可用的字典。"""
+ self.ensure_one()
+ return {
+ 'emp_id': self.id,
+ 'level': self.game_level,
+ 'exp': self.game_exp,
+ 'exp_max': self.game_exp_max,
+ 'title': self.game_title,
+ 'coins': self.game_coins,
+ 'energy': self.game_energy,
+ 'wood': self.game_wood,
+ 'stone': self.game_stone,
+ 'world_name': self.game_world_name or '',
+ 'era': self.game_era or '',
+ 'tiles': self.game_tiles,
+ 'population': self.game_population,
+ 'civ_level': self.game_civ_level,
+ 'game_uid': self.game_uid or '',
+ 'reg_date': str(self.game_reg_date) if self.game_reg_date else None,
+ 'achievement_count': self.game_achievement_count,
+ 'unread_mail': self.game_unread_mail,
+ }
diff --git a/addons/game_base/models/game_mail.py b/addons/game_base/models/game_mail.py
new file mode 100644
index 0000000..dbea734
--- /dev/null
+++ b/addons/game_base/models/game_mail.py
@@ -0,0 +1,28 @@
+# -*- coding: utf-8 -*-
+from odoo import api, fields, models
+
+
+class GamePlayerMail(models.Model):
+ """玩家邮件:后台可发送系统邮件给指定玩家(员工)。"""
+ _name = 'game.player.mail'
+ _description = '玩家邮件'
+ _order = 'date desc, id desc'
+ _rec_name = 'subject'
+
+ CATEGORY = [
+ ('system', '系统'),
+ ('activity', '活动'),
+ ('reward', '奖励'),
+ ('announcement', '公告'),
+ ]
+
+ employee_id = fields.Many2one(
+ 'hr.employee', '收件人',
+ required=True, ondelete='cascade', index=True)
+ category = fields.Selection(CATEGORY, '分类', default='system', index=True)
+ subject = fields.Char('标题', required=True)
+ body = fields.Html('正文')
+ date = fields.Datetime('发送时间', default=fields.Datetime.now)
+ is_read = fields.Boolean('已读', default=False)
+ is_pinned = fields.Boolean('置顶', default=False)
+ sender = fields.Char('发件人', default='系统')
diff --git a/addons/game_base/security/ir.model.access.csv b/addons/game_base/security/ir.model.access.csv
index d0a25cc..37155c0 100644
--- a/addons/game_base/security/ir.model.access.csv
+++ b/addons/game_base/security/ir.model.access.csv
@@ -19,3 +19,9 @@ access_game_story_volume,game.story.volume public,model_game_story_volume,base.g
access_game_story_volume_user,game.story.volume user,model_game_story_volume,base.group_user,1,1,1,1
access_game_story_chapter,game.story.chapter public,model_game_story_chapter,base.group_public,1,0,0,0
access_game_story_chapter_user,game.story.chapter user,model_game_story_chapter,base.group_user,1,1,1,1
+access_game_achievement_template,game.achievement.template public,model_game_achievement_template,base.group_public,1,0,0,0
+access_game_achievement_template_user,game.achievement.template user,model_game_achievement_template,base.group_user,1,1,1,1
+access_game_player_achievement,game.player.achievement public,model_game_player_achievement,base.group_public,1,0,0,0
+access_game_player_achievement_user,game.player.achievement user,model_game_player_achievement,base.group_user,1,1,1,1
+access_game_player_mail,game.player.mail public,model_game_player_mail,base.group_public,1,0,0,0
+access_game_player_mail_user,game.player.mail user,model_game_player_mail,base.group_user,1,1,1,1
diff --git a/addons/game_base/views/forum_views.xml b/addons/game_base/views/forum_views.xml
index 080c6a5..437a770 100644
--- a/addons/game_base/views/forum_views.xml
+++ b/addons/game_base/views/forum_views.xml
@@ -28,7 +28,7 @@
-
+
diff --git a/addons/game_base/views/game_achievement_views.xml b/addons/game_base/views/game_achievement_views.xml
new file mode 100644
index 0000000..8b392c0
--- /dev/null
+++ b/addons/game_base/views/game_achievement_views.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+ game.achievement.template.tree
+ game.achievement.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ game.achievement.template.form
+ game.achievement.template
+
+
+
+
+
+
+
+ 成就模板
+ game.achievement.template
+ tree,form
+
+
+
+
+ game.player.achievement.tree
+ game.player.achievement
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 玩家成就
+ game.player.achievement
+ tree,form
+
+
+
diff --git a/addons/game_base/views/game_employee_views.xml b/addons/game_base/views/game_employee_views.xml
new file mode 100644
index 0000000..bca1b93
--- /dev/null
+++ b/addons/game_base/views/game_employee_views.xml
@@ -0,0 +1,60 @@
+
+
+
+
+
+ hr.employee.game.form
+ hr.employee
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ hr.employee.game.tree
+ hr.employee
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/game_base/views/game_mail_views.xml b/addons/game_base/views/game_mail_views.xml
new file mode 100644
index 0000000..c634379
--- /dev/null
+++ b/addons/game_base/views/game_mail_views.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+ game.player.mail.tree
+ game.player.mail
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ game.player.mail.form
+ game.player.mail
+
+
+
+
+
+
+
+ 玩家邮件
+ game.player.mail
+ tree,form
+
+
+
diff --git a/addons/game_base/views/menu.xml b/addons/game_base/views/menu.xml
index daa2008..305054f 100644
--- a/addons/game_base/views/menu.xml
+++ b/addons/game_base/views/menu.xml
@@ -26,6 +26,11 @@
+
+
+
+
+
@@ -37,5 +42,6 @@
+
diff --git a/assets/game-api.js b/assets/game-api.js
index 4029431..f8a374a 100644
--- a/assets/game-api.js
+++ b/assets/game-api.js
@@ -59,6 +59,19 @@ const API = {
method: 'POST', credentials: _cred,
})).json();
},
+ async achievements() {
+ return (await fetch(_url('/game/api/achievements'), { credentials: _cred })).json();
+ },
+ async mails() {
+ return (await fetch(_url('/game/api/mails'), { credentials: _cred })).json();
+ },
+ async mailRead(mailId) {
+ return (await fetch(_url('/game/api/mail/read'), {
+ method: 'POST', credentials: _cred,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ mail_id: mailId }),
+ })).json();
+ },
async codex() {
return (await fetch(_url('/game/api/codex'), { credentials: _cred })).json();
},
@@ -123,4 +136,6 @@ const API = {
return await r.json();
} catch (e) { return null; }
},
+ // 暴露后端 base,供图片等静态资源 URL 拼接(本地跨域→8069;线上同域→'')
+ base: API_CONFIG.base,
};
diff --git a/assets/site-app.js b/assets/site-app.js
index c50403e..b8e13af 100644
--- a/assets/site-app.js
+++ b/assets/site-app.js
@@ -53,6 +53,102 @@ async function loadRules(){
} catch (e) { /* 保留静态兜底内容 */ }
}
+// ---------- 图鉴 ----------
+async function loadCodex(){
+ const grid = document.getElementById('codexGrid');
+ const catsEl = document.getElementById('codexCats');
+ const subsEl = document.getElementById('codexSubs');
+ const searchEl= document.getElementById('codexSearch');
+ const countEl = document.getElementById('codexCount');
+ if (!grid) return;
+
+ let data;
+ try { data = await API.codex(); }
+ catch (e) { return; } // 失败时保留静态兜底内容
+
+ const groups = (data && data.groups) || [];
+ if (!groups.length){
+ grid.innerHTML = '暂无图鉴数据
';
+ return;
+ }
+
+ // 选中状态
+ let curGroup = groups[0].key;
+ let curSub = 'all';
+ let curQ = '';
+
+ function renderCats(){
+ if (!catsEl) return;
+ catsEl.innerHTML = groups.map(g =>
+ ``
+ ).join('');
+ catsEl.querySelectorAll('button').forEach(b => b.addEventListener('click', () => {
+ curGroup = b.dataset.gk; curSub = 'all'; curQ = '';
+ if (searchEl) searchEl.value = '';
+ renderCats(); renderSubs(); renderGrid();
+ }));
+ }
+
+ function renderSubs(){
+ if (!subsEl) return;
+ const g = groups.find(x => x.key === curGroup);
+ const subs = (g && g.subs) || [];
+ subsEl.innerHTML = `` +
+ subs.map(s => ``).join('');
+ subsEl.querySelectorAll('button').forEach(b => b.addEventListener('click', () => {
+ curSub = b.dataset.sn; renderSubs(); renderGrid();
+ }));
+ }
+
+ function renderGrid(){
+ const g = groups.find(x => x.key === curGroup);
+ if (!g){ grid.innerHTML = ''; return; }
+ // 按子分类筛选
+ let items = [];
+ (g.subs || []).forEach(s => {
+ (s.items || []).forEach(it => {
+ if (curSub !== 'all' && it.sub !== curSub) return;
+ items.push(it);
+ });
+ });
+ // 搜索过滤
+ if (curQ){
+ const q = curQ.toLowerCase();
+ items = items.filter(it =>
+ (it.name || '').toLowerCase().includes(q) ||
+ (it.desc || '').replace(/<[^>]+>/g, '').toLowerCase().includes(q)
+ );
+ }
+ if (countEl) countEl.textContent = items.length + ' 项';
+ if (!items.length){
+ grid.innerHTML = '未找到匹配项
';
+ return;
+ }
+ const base = API.base || '';
+ grid.innerHTML = items.map(it => {
+ const thumb = it.image
+ ? ``
+ : ``;
+ return `
+ ${thumb}
+
+
${it.name || ''}
+
${it.desc || ''}
+
${it.sub || ''}${it.tier || ''}
+
+
`;
+ }).join('');
+ }
+
+ if (searchEl){
+ searchEl.addEventListener('input', () => { curQ = searchEl.value.trim(); renderGrid(); });
+ }
+
+ renderCats();
+ renderSubs();
+ renderGrid();
+}
+
// ---------- 论坛 ----------
async function loadForum(){
const threadsEl = document.getElementById('threads');
@@ -155,6 +251,7 @@ async function loadStory(){
document.addEventListener('DOMContentLoaded', () => {
loadPages();
loadRules();
+ loadCodex();
loadForum();
loadAnnounce();
loadStory();
diff --git a/index.html b/index.html
index 01492da..de205b8 100644
--- a/index.html
+++ b/index.html
@@ -182,6 +182,7 @@
.cx .meta{display:flex;justify-content:space-between;margin-top:5px;font-size:11px;}
.cx .meta .sub{color:var(--cyan);}
.cx .meta .tier{color:var(--gold);}
+ .cx .desc{font-size:12px;color:var(--text-dim);line-height:1.5;margin-top:4px;max-height:60px;overflow:hidden;}
.cx .ds{font-size:11.5px;color:var(--text-dim);line-height:1.5;margin-top:6px;}
/* ===== 论坛 ===== */
@@ -437,68 +438,8 @@
@@ -518,12 +459,8 @@
-
全部
-
综合讨论
-
攻略
-
背景考据
-
作品秀
-
反馈
+
+
@@ -532,12 +469,8 @@
-
置顶【公告】宇森官网第一阶段上线,欢迎园丁们入驻128 回复 · 官方
-
新手向:园丁视角的三种开局思路42 回复 · 行者
-
你们更喜欢哪种族?来投个票76 回复 · 雾岛
-
手绘了我的主世界浮空岛,求轻喷31 回复 · 青柠
-
反馈:飞入微观时偶现卡顿9 回复 · K
-
地下矿脉怎么规划最省心?18 回复 · 老周
+
+
@@ -545,84 +478,8 @@
-
-
-
-
2026.07.09 · 官方
-
宇森官网第一阶段上线,欢迎园丁们入驻
-
-
历经三个月打磨,宇森官网第一阶段正式上线。本次更新带来八大板块——介绍、特色、背景、规则、图鉴、论坛、公告、致玩家,全部以单页切换形式呈现,介绍页即一段概念宣传片,让你在三秒内读懂我们要做什么。
-
「园丁视角」是宇森的核心:你不是旁观者,而是世界的第一位居民。上传你的世界种子,观看五层世界自上而下生长——从星空陨石带到地心熔岩,每一寸地形、每一个种族,都由你的选择塑造。
-
我们同步开放了社区论坛与图鉴共建入口。欢迎在「论坛」留下你的构想,或在「图鉴」认领尚未完成的种族条目;被采纳的内容,会带上你的署名进入正式世界观。
-
-
-
-
-
-
-
2026.07.09
-
图鉴系统全面升级:204 种族 + 程序化矢量插画
-
-
图鉴系统完成一次大版本升级:种族条目扩充至 204 条,系别、性格、天赋、神器与传奇事件全面扩量,装备槽位对齐「头 / 手 / 胸 / 裤 / 鞋 / 戒指(双)/ 项链 / 宝物」九宫格。
-
所有缩略图改用程序化矢量插画生成,零 AI 生图、零版权风险,且在任意分辨率下都不糊。后续会逐步开放玩家投稿的「同人词条」分区。
-
-
-
-
-
-
-
2026.07.08
-
概念宣传片《垂直生长》完成
-
-
概念宣传片《垂直生长》正式杀青。片长 90 秒,以「自上而下」的镜头语言呈现五层世界的生长过程:天空层改为云朵地基块,星空层改为陨石科技带,地心则翻涌着熔岩血脉。
-
这支片子将成为官网介绍页的自动背景,也会在后续对外宣发中使用。如果你喜欢这种克制的科幻基调,记得在论坛给我们反馈。
-
-
-
-
-
-
-
2026.07.01
-
园丁视角核心玩法原型启动
-
-
园丁视角核心玩法原型进入验证阶段。首版采用六边形地图,配合「活 NPC」系统——NPC 拥有独立的日程、记忆与情绪,会对你的长期行为产生不同反应。
-
这一阶段我们重点关注「放手感」:玩家拖拽、缩放、种植、引导文明,每一步都要足够顺滑。手感达标后,再叠加叙事与冲突。
-
-
-
-
-
-
-
2026.06.25
-
五大世界层设定公开
-
-
我们公开了宇森的底层世界观——五大世界层:星空、天空、大地、地下、地心。每一层对应一种文明形态与资源循环,层与层之间通过「根系」与「星轨」双向连通。
-
这套设定不是装饰,而是玩法骨架:你在大地的每一次开采,都会改变地心的压力;地心的每一次喷发,又会重塑天空的气候。
-
-
-
-
-
-
-
2026.06.20
-
社区共建计划:你的名字可以写进世界观
-
-
图鉴系统规划正式确定,分为「种族 / 建筑 / 生物 / 神器」四大类目。我们决定把相当一部分条目开放给社区共建。
-
被采纳的投稿会标注作者署名,并永久保留在官方图鉴中。第一批评审将在论坛「共建」分区进行,欢迎有想法的园丁报名。
-
-
-
-
-
-
-
2026.06.12
-
限量内测招募开启
-
-
宇森首轮封闭内测招募正式开启,名额 500 人。我们将优先邀请在论坛持续输出高质量内容的园丁,以及愿意参与玩法共创的核心用户。
-
内测的重点不是「好不好玩」,而是「哪里别扭」——你的每一条吐槽,都会直接进入我们的周度复盘会。
-
-
-
+
+
diff --git a/world.html b/world.html
index e813d30..cf5695a 100644
--- a/world.html
+++ b/world.html
@@ -619,51 +619,49 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa