From ffd4bb8f080594d7c238b265c48c91a24ebaf3c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E9=B9=8F=E5=AE=87?= Date: Tue, 28 Jul 2026 19:06:32 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B3=A8=E5=86=8C=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- addons/game_base/__manifest__.py | 12 +- addons/game_base/controllers/game_api.py | 162 +++++++++- .../game_base/data/game_achievement_data.xml | 95 ++++++ addons/game_base/models/__init__.py | 3 + addons/game_base/models/game_achievement.py | 85 ++++++ addons/game_base/models/game_employee.py | 74 +++++ addons/game_base/models/game_mail.py | 28 ++ addons/game_base/security/ir.model.access.csv | 6 + addons/game_base/views/forum_views.xml | 2 +- .../views/game_achievement_views.xml | 81 +++++ .../game_base/views/game_employee_views.xml | 60 ++++ addons/game_base/views/game_mail_views.xml | 56 ++++ addons/game_base/views/menu.xml | 6 + assets/game-api.js | 15 + assets/site-app.js | 97 ++++++ index.html | 161 +--------- world.html | 278 ++++++++++++------ 17 files changed, 977 insertions(+), 244 deletions(-) create mode 100644 addons/game_base/data/game_achievement_data.xml create mode 100644 addons/game_base/models/game_achievement.py create mode 100644 addons/game_base/models/game_employee.py create mode 100644 addons/game_base/models/game_mail.py create mode 100644 addons/game_base/views/game_achievement_views.xml create mode 100644 addons/game_base/views/game_employee_views.xml create mode 100644 addons/game_base/views/game_mail_views.xml 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 @@
- -
-

① 世界等级 · 五阶段

-

世界从凡墟到溯元的整体进化阶段,决定可解锁的内容深度与事件池上限。园丁对世界施加的影响会推动阶段跃迁——这是文明能"发生什么"的舞台上限。

-
-
阶段一凡墟万物生灵尚处蒙昧凡土,天地灵气稀薄,各族散居荒野,无族群联盟与政权,只求狩猎采集求生,一切秩序遵循自然法则。
-
阶段二灵启天地灵气复苏觉醒,各族聚拢形成聚落,诞生劳作分工、屋舍营建与物物交换,不同族群率先凝聚部族,世间初代势力自此诞生。
-
阶段三玄界各族部落兼并融合,广袤大地形成多元族群共治的完整疆域,征伐、道统、造物三大发展路线并行;各族大能、上古秘闻等传奇事件不断现世,玄荒文明迎来鼎盛割据时代。
-
阶段四穹冥生灵突破地表桎梏,打通地底深渊与浩瀚星空,天地上下各界尽数纳入族群管辖;各类上古神器、本命法器完整解锁,文明疆域遍布天地穹苍。
-
阶段五溯元各族顶尖文明勘破这片天地的诞生本源,挣脱本世界规则束缚,可踏上飞升之路、往复轮回,前往更高维度开启全新文明演化。
-
-
- -
-

② 势力等级

-

文明内部群体的聚合度,由人口、领土、影响力累计。势力等级越高,可调动的世界资源与外交权重越大——决定文明"能调动什么"。

-
-
Lv.1墟族血缘小群体,蛮荒无制度。
-
Lv.2灵聚灵气聚落联盟,初步治理与贸易。
-
Lv.3玄邑疆域整合,军法、道规体系完善。
-
Lv.4冥朝一统大地,掌控地下浅层资源调度。
-
Lv.5穹统天地大陆霸主,穿梭星空深渊作战。
-
Lv.6溯盟多维文明联合体,触及世界本源博弈。
-
-
- -
-

③ 人物角色等级 · 十二阶段

-

个体(英雄 / 领袖 / 特殊 NPC)的成长阶序,通过历练、天赋点亮与装备积累提升,影响可承担的职责与战力——决定个体"能做成什么"。

-
-
Lv.1萌新初入世界的无名者。
-
Lv.2学徒掌握基础技艺。
-
Lv.3熟手可独立完成任务。
-
Lv.4好手在某领域崭露头角。
-
Lv.5精英群体中的中坚。
-
Lv.6大师技艺臻于化境。
-
Lv.7宗匠开宗立派的人物。
-
Lv.8传奇事迹被载入编年史。
-
Lv.9神话近乎传说的存在。
-
Lv.10半神触及世界法则。
-
Lv.11圣者被信仰托举的意志。
-
Lv.12永恒超越生死的世界锚点。
-
-
- -
-

④ 制度说明

-

三者相互独立又彼此加成:世界等级是舞台上限,决定能发生什么;势力等级是群体进度,决定能调动什么;角色等级是个体成长,决定能做成什么。园丁的核心策略,是在三者间分配有限的引导资源,让文明自洽地向上演化。

跃迁逻辑:角色历练与装备积累→提升势力实力→推动世界阶段跃迁→解锁更深内容(更深的地下层、更远的星空层、更强的神器)。每一级提升都会扩大可叙事的事件池,世界因此越活越厚。

-
- -
-

⑤ 货币等级 · 六档

-

文明流通的价值尺度,从凡俗零钱到本源货币,越稀有的货币可交易的资源层级越高——决定文明"能交换什么"。

兑换规则:100 低等级货币 = 1 高等级货币。

-
-
Lv.1墟铢凡俗通用零钱,以贝壳、骨片、碎石铸成。100 墟铢 = 1 灵币
-
Lv.2灵币蕴含微量灵气的金属铸币,聚落以上势力日常使用。100 灵币 = 1 玄锭
-
Lv.3玄锭刻有玄纹的稀有锭块,用于高价值交易与功法图纸。100 玄锭 = 1 冥玉
-
Lv.4冥玉产自地底深渊的幽暗玉石,蕴含浓郁能量。100 冥玉 = 1 穹鎏
-
Lv.5穹鎏来自星空矿脉的稀有鎏金晶体,跨层贸易硬通货。100 穹鎏 = 1 元玺
-
Lv.6元玺追溯世界本源凝聚而成的至高货币,可兑换神器、传说事件与维度权限。
-
-
+ +
@@ -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
- +
- Lv.12 - 星界旅人 + Lv.-- + --
-
-
EXP 640 / 1000 · 距下一级还差 360
+
+
EXP -- / --
- +
🌍 我的世界
-
我的世界
+
--
-
星元1年·春
历元
-
0
疆域地块
-
382
人口
-
68%
文明度
+
--
历元
+
--
疆域地块
+
--
人口
+
--
文明度
-
🪙 金币1280
-
✨ 灵气540
-
🪵 木材360
-
🪨 石料180
+
🪙 金币--
+
✨ 灵气--
+
🪵 木材--
+
🪨 石料--
- +
-
🏆 成就
-
-
🌟
开天辟地
已点亮第一块领土
-
🌱
文明摇篮
-
🔒
通天塔
建造 10 座奇观解锁
+
🏆 成就 -- / --
+
+
- + + + - - - +
@@ -4364,17 +4362,94 @@ if (typeof fitWorld === 'function') fitWorld(); trigger.setAttribute('aria-expanded', open ? 'true' : 'false'); }; - // 面板内容(游戏视角 demo):同步真实可读字段 - const uaName2 = document.getElementById('uaName2'); - const uaAv = document.getElementById('uaAvatarLg'); - const wnEl2 = document.getElementById('worldName'); - const eraEl = document.getElementById('calendarLabel'); - const tilesEl = document.getElementById('uaTiles'); - if (uaName2) uaName2.textContent = me.name || me.login; - if (uaAv) uaAv.textContent = (me.name || me.login || 'M').trim().charAt(0).toUpperCase(); - if (wnEl2) { const wn = document.getElementById('uaWorldName'); if (wn) wn.textContent = wnEl2.textContent; } - if (eraEl) { const er = document.getElementById('uaEra'); if (er) er.textContent = eraEl.textContent; } - if (tilesEl && typeof tiles !== 'undefined' && Array.isArray(tiles)) tilesEl.textContent = tiles.length; + // === 面板数据填充(从 me() 返回值动态渲染) === + const cachedMe = me; // 保存供弹窗复用 + function fillPanelData(d) { + const name = d.name || d.login || ''; + const uaName2 = document.getElementById('uaName2'); + if (uaName2) uaName2.textContent = name; + const uaAv = document.getElementById('uaAvatarLg'); + if (uaAv) uaAv.textContent = (name || 'M').trim().charAt(0).toUpperCase(); + const uaUid = document.getElementById('uaUid'); + if (uaUid) uaUid.textContent = d.game_uid || d.uid || '--'; + // 等级卡 + const uaLv = document.getElementById('uaLv'); + if (uaLv) uaLv.textContent = d.level || 1; + const uaTitle = document.getElementById('uaTitle'); + if (uaTitle) uaTitle.textContent = d.title || '萌新'; + const expMax = d.exp_max || 100; + const expPct = expMax > 0 ? Math.min(100, (d.exp || 0) / expMax * 100) : 0; + const uaExpBar = document.getElementById('uaExpBar'); + if (uaExpBar) uaExpBar.style.width = expPct + '%'; + const uaExpTxt = document.getElementById('uaExpTxt'); + if (uaExpTxt) uaExpTxt.textContent = 'EXP ' + (d.exp || 0) + ' / ' + expMax; + // 世界概览 + const uaWorldName = document.getElementById('uaWorldName'); + if (uaWorldName) uaWorldName.textContent = d.world_name || '我的世界'; + const uaEra = document.getElementById('uaEra'); + if (uaEra) uaEra.textContent = d.era || '--'; + const uaTiles = document.getElementById('uaTiles'); + if (uaTiles) uaTiles.textContent = d.tiles || 0; + const uaPop = document.getElementById('uaPop'); + if (uaPop) uaPop.textContent = d.population || 0; + const uaCiv = document.getElementById('uaCiv'); + if (uaCiv) uaCiv.textContent = d.civ_level || 0; + // 资源条(按上限折算宽度) + function setRes(barId, valId, val, max) { + const el = document.getElementById(valId); + if (el) el.textContent = val || 0; + const bar = document.getElementById(barId); + if (bar) bar.style.width = (max > 0 ? Math.min(100, (val || 0) / max * 100) : 0) + '%'; + } + setRes('uaCoinBar', 'uaCoins', d.coins, 10000); + setRes('uaEnergyBar', 'uaEnergy', d.energy, 1000); + setRes('uaWoodBar', 'uaWood', d.wood, 1000); + setRes('uaStoneBar', 'uaStone', d.stone, 1000); + // 邮件未读角标 + const uaMailBadge = document.getElementById('uaMailBadge'); + if (uaMailBadge) { + const n = d.unread_mail || 0; + uaMailBadge.textContent = n; + uaMailBadge.style.display = n > 0 ? '' : 'none'; + } + } + fillPanelData(me); + + // === 异步加载成就预览 + 邮件未读数 === + (async () => { + try { + const ach = await API.achievements(); + if (ach && ach.items) { + const summary = document.getElementById('uaAchSummary'); + if (summary) summary.textContent = (ach.unlocked_count || 0) + ' / ' + (ach.total_count || 0); + const preview = document.getElementById('uaAchPreview'); + if (preview) { + const top3 = ach.items.slice(0, 3); + preview.innerHTML = top3.length ? top3.map(a => { + const icon = a.unlocked ? (a.icon || '🏆') : '🔒'; + const pct = a.target > 0 ? Math.min(100, (a.progress || 0) / a.target * 100) : (a.unlocked ? 100 : 0); + return `
+ ${icon} +
+
${a.name}
+
+
+
`; + }).join('') : '
暂无成就数据
'; + } + } + } catch (e) { console.warn('加载成就预览失败', e); } + try { + const ml = await API.mails(); + if (ml && typeof ml.unread === 'number') { + const badge = document.getElementById('uaMailBadge'); + if (badge) { + badge.textContent = ml.unread; + badge.style.display = ml.unread > 0 ? '' : 'none'; + } + } + } catch (e) { console.warn('加载邮件数失败', e); } + })(); trigger.addEventListener('click', (ev) => { ev.stopPropagation(); @@ -4418,27 +4493,40 @@ if (typeof fitWorld === 'function') fitWorld(); if (e.target === appModal) closeAppModal(); }); - // 个人信息 + // 个人信息(从 cachedMe 动态渲染) const uaProfile = document.getElementById('uaProfile'); if (uaProfile) uaProfile.addEventListener('click', () => { + const d = cachedMe; + const name = d.name || d.login || ''; + const avatarLetter = (name || 'M').trim().charAt(0).toUpperCase(); + const expMax = d.exp_max || 100; + const expPct = expMax > 0 ? Math.min(100, (d.exp || 0) / expMax * 100) : 0; openAppModal('👤 个人信息', `
-
M
+
${avatarLetter}
-
Mitchell
-
UID: 10086001
+
${name}
+
UID: ${d.game_uid || d.uid || '--'}
-
等级Lv.12
+
等级Lv.${d.level || 1}
-
+
-
EXP 640 / 1000
+
EXP ${d.exp || 0} / ${expMax}
-
金币🪙 1280
-
世界星元 1年 · 春
-
注册2026-07-20
+
称号${d.title || '萌新'}
+
金币🪙 ${d.coins || 0}
+
灵气✨ ${d.energy || 0}
+
木材🪵 ${d.wood || 0}
+
石料🪨 ${d.stone || 0}
+
世界${d.world_name || '我的世界'}
+
历元${d.era || '--'}
+
疆域${d.tiles || 0} 块
+
人口${d.population || 0}
+
文明度${d.civ_level || 0}
+
注册${d.reg_date || '--'}
`); }); @@ -4475,57 +4563,81 @@ if (typeof fitWorld === 'function') fitWorld(); }; }); - // 邮件 + // 邮件(从 API.mails() 动态取值) const uaMessages = document.getElementById('uaMessages'); - if (uaMessages) uaMessages.addEventListener('click', () => { - const mails = [ - { cat: '系统', title: '欢迎来到宇森', date: '2026-07-26', body: '请查收新手礼包与世界探索指南。', unread: true }, - { cat: '系统', title: '版本更新 v0.2', date: '2026-07-25', body: '新增世界导航与微观视图功能。', unread: false }, - { cat: '活动', title: '夏日创作大赛', date: '2026-07-24', body: '上传你的世界截图,赢限定头像框。', unread: true } - ]; - const cats = ['全部', '系统', '活动']; + if (uaMessages) uaMessages.addEventListener('click', async () => { + openAppModal('✉️ 邮件', '
加载中…
'); + let mails = []; + try { + const resp = await API.mails(); + mails = (resp && resp.items) || []; + } catch (e) { + if (appModalBody) appModalBody.innerHTML = '
邮件加载失败
'; + return; + } + const cats = ['全部', ...new Set(mails.map(m => m.category_label).filter(Boolean))]; function renderMail(cat) { - const list = cat === '全部' ? mails : mails.filter(m => m.cat === cat); + const list = cat === '全部' ? mails : mails.filter(m => m.category_label === cat); const tabs = cats.map(c => `${c}`).join(''); const items = list.length - ? list.map(m => `
${m.unread ? '● ' : ''}${m.title}
${m.cat} · ${m.date}
${m.body}
`).join('') + ? list.map(m => `
+
${!m.is_read ? '● ' : ''}${m.subject || '(无标题)'}
+
${m.category_label || ''} · ${m.date || ''}${m.sender ? ' · ' + m.sender : ''}
+
${m.body || ''}
+
`).join('') : '
暂无邮件
'; if (appModalBody) appModalBody.innerHTML = `
${tabs}
${items}`; appModalBody.querySelectorAll('.mailTab').forEach(t => { t.onclick = () => renderMail(t.dataset.cat); }); + appModalBody.querySelectorAll('.mailItem').forEach(item => { + item.onclick = async () => { + const id = parseInt(item.dataset.id); + if (!id) return; + try { await API.mailRead(id); } catch (e) {} + const m = mails.find(x => x.id === id); + if (m) m.is_read = true; + const badge = document.getElementById('uaMailBadge'); + const unread = mails.filter(x => !x.is_read).length; + if (badge) { badge.textContent = unread; badge.style.display = unread > 0 ? '' : 'none'; } + const activeTab = appModalBody.querySelector('.mailTab.active'); + renderMail(activeTab ? activeTab.dataset.cat : '全部'); + }; + }); } - openAppModal('✉️ 邮件', ''); renderMail('全部'); }); - // 成就 + // 成就(从 API.achievements() 动态取值) const uaAchievements = document.getElementById('uaAchievements'); - if (uaAchievements) uaAchievements.addEventListener('click', () => { - openAppModal('🏆 成就', ` + if (uaAchievements) uaAchievements.addEventListener('click', async () => { + openAppModal('🏆 成就', '
加载中…
'); + let ach = { items: [], total_points: 0, unlocked_count: 0, total_count: 0 }; + try { + ach = await API.achievements(); + } catch (e) { + if (appModalBody) appModalBody.innerHTML = '
成就加载失败
'; + return; + } + const items = ach.items || []; + const html = `
-
320
成就点数
-
8 / 20
已解锁
+
${ach.total_points || 0}
成就点数
+
${ach.unlocked_count || 0} / ${ach.total_count || 0}
已解锁
-
-
🏆
-
初临世界
创建你的第一个世界
-
已达成
-
-
-
🌍
-
五界行者
解锁全部五层世界
-
已达成
-
-
-
🔒
-
大地建筑师
在大地世界放置 50 个地块
-
40%
-
-
-
🔒
-
熔岩征服者
在地心世界停留 10 分钟
-
15%
-
- `); + ${items.length ? items.map(a => { + const pct = a.target > 0 ? Math.min(100, (a.progress || 0) / a.target * 100) : (a.unlocked ? 100 : 0); + const icon = a.unlocked ? (a.icon || '🏆') : '🔒'; + return `
+
${icon}
+
+
${a.name}
+
${a.desc || ''}${a.tier_label ? ' [' + a.tier_label + ']' : ''} · ${a.points}点
+ ${a.unlocked ? '' : `
`} +
+
${a.unlocked ? '已达成' : Math.round(pct) + '%'}
+
`; + }).join('') : '
暂无成就数据
'} + `; + if (appModalBody) appModalBody.innerHTML = html; }); } catch (e) { location.replace('login.html?next=world.html'); return;