feat: 前端 UI 精修 + 后端视图调整 + 资源预置

前端:
- world.html 游戏内聊天组件重构:表情选择器改固定方格+自适应列+去滚动条;
  输入框清空占位文字+隐藏滚动条;聊天面板整体高度缩短约 1/3;
  顶部用户区改为纯文字并点开个人信息弹窗(头像/头像框/性别/说明/称号/注册)
- chat.html 聊天输入框压扁(46→34px)
- codex.html 新增神话品质、天赋/技能书/书籍分类与预置数据
- rules.html 规则文档更新

后端(Odoo):
- game_base 附件改为看板视图(图片/名称/备注/大小)
- yt_world 玩家菜单直接指向 hr.employee
- game_base 员工表单隐藏原生联系人/组织架构页签
- chat 模型/接口/视图与规则数据扩充

资源:生成 4x4 资源精灵表(树木/石材/芦苇/宝箱)
This commit is contained in:
李鹏宇
2026-08-14 14:27:15 +08:00
parent d713221581
commit 73d764938d
16 changed files with 1713 additions and 388 deletions
+29 -5
View File
@@ -2,6 +2,7 @@
from odoo import http
from odoo.http import request
from .game_api import _json, _post_body
from ..models.chat import GUIDE_NAME
class ChatApiController(http.Controller):
@@ -12,13 +13,14 @@ class ChatApiController(http.Controller):
@http.route('/game/api/chat/channels', type='http', auth='public',
methods=['GET'], csrf=False)
def chat_channels(self, **kw):
"""当前用户可见频道:世界频道(公开) + 自己参与的私聊/公会。"""
"""当前用户可见频道:世界频道 + AI向导频道(均公开) + 自己参与的私聊/公会。"""
uid = request.session.uid
Channel = request.env['game.chat.channel'].sudo()
domain = [('channel_type', '=', 'world')]
public = [('channel_type', 'in', ['world', 'guide'])]
if uid:
domain = ['|', ('channel_type', '=', 'world'),
('member_ids', 'in', [uid])]
domain = ['|'] + public + [('member_ids', 'in', [uid])]
else:
domain = public
recs = Channel.search_read(domain, ['id', 'name', 'channel_type'],
order='sequence, id')
return _json({'items': [{
@@ -60,6 +62,7 @@ class ChatApiController(http.Controller):
'create_date': str(r['create_date']),
'is_self': bool(uid and r.get('author_id')
and r['author_id'][0] == uid),
'is_guide': bool(r['author_name'] == GUIDE_NAME),
} for r in recs]
return _json({'items': items})
@@ -92,6 +95,26 @@ class ChatApiController(http.Controller):
'author_name': author_name,
'body': body,
})
# AI 向导自动回复:仅对 guide 类型频道生效,与世界频道隔离
guide_reply = None
ch = request.env['game.chat.channel'].sudo().browse(channel_id)
if ch.exists() and ch.channel_type == 'guide':
reply_text = ch.generate_guide_reply(body)
if reply_text:
reply = request.env['game.chat.message'].sudo().create({
'channel_id': channel_id,
'author_id': False,
'author_name': GUIDE_NAME,
'body': reply_text,
})
guide_reply = {
'id': reply.id,
'author_name': GUIDE_NAME,
'body': reply_text,
'create_date': str(reply.create_date),
'is_self': False,
'is_guide': True,
}
return _json({
'ok': True,
'message': {
@@ -100,5 +123,6 @@ class ChatApiController(http.Controller):
'body': body,
'create_date': str(msg.create_date),
'is_self': True,
}
},
'guide_reply': guide_reply,
})
+11 -3
View File
@@ -22,10 +22,18 @@ def _json(data):
def _post_body():
try:
raw = request.httprequest.get_data(as_text=True) or '{}'
return json.loads(raw)
# Odoo 官方解析 JSON 请求体(正确处理 UTF-8),优先使用。
return request.get_json_data()
except Exception:
return {}
try:
# 兜底:显式按 UTF-8 解码原始字节,避免 Werkzeug 在 application/json
# 无 charset 时按 latin1 解码中文导致乱码。
raw = request.httprequest.get_data(cache=True) or b'{}'
if isinstance(raw, str):
raw = raw.encode('utf-8')
return json.loads(raw.decode('utf-8'))
except Exception:
return {}
class GameApiController(http.Controller):
+9 -2
View File
@@ -1,11 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- 默认世界频道:所有玩家可见、可发言 -->
<data>
<!-- 默认世界频道:所有玩家可见、可发言(纯净玩家聊天,不混入 AI 向导) -->
<record model="game.chat.channel" id="channel_world">
<field name="name">世界频道</field>
<field name="channel_type">world</field>
<field name="sequence">10</field>
</record>
<!-- 默认游戏助手频道:与世界频道物理隔离,玩家提问、系统自动回复(知识引擎) -->
<record model="game.chat.channel" id="channel_guide">
<field name="name">游戏助手</field>
<field name="channel_type">guide</field>
<field name="sequence">20</field>
</record>
</data>
</odoo>
+75
View File
@@ -1,6 +1,17 @@
# -*- coding: utf-8 -*-
import re
from odoo import models, fields
# 游戏助手的展示昵称(前后端共用,落库消息的作者名)
GUIDE_NAME = '游戏助手'
def _plain(text):
"""去掉 HTML 标签,用于把 rule.desc(Html) 当作纯文本兜底。"""
if not text:
return ''
return re.sub(r'<[^>]+>', '', text).strip()
class GameChatChannel(models.Model):
_name = 'game.chat.channel'
@@ -12,12 +23,76 @@ class GameChatChannel(models.Model):
('world', '世界频道'),
('guild', '公会'),
('pm', '私聊'),
('guide', 'AI向导'),
], string='类型', default='world', index=True)
sequence = fields.Integer(string='排序', default=10)
creator_id = fields.Many2one('res.users', string='创建者')
member_ids = fields.Many2many('res.users', string='成员')
message_ids = fields.One2many('game.chat.message', 'channel_id', string='消息')
def generate_guide_reply(self, question):
"""内置游戏知识引擎(零外部依赖):按关键词检索 game.rule / game.rule.category 作答。
预留扩展点:若需接入真实 LLM,替换本方法实现、返回模型生成的文本即可,其余调用方不变。
"""
q = (question or '').strip()
if not q:
return '你好,我是游戏助手。你可以问我关于「属性」「等级」「货币」「制度」的问题~'
env = self.env
Rule = env['game.rule']
Cat = env['game.rule.category']
def rules_of(key):
c = Cat.search([('key', '=', key)], limit=1)
return Rule.search([('category_id', '=', c.id)], order='name') if c else []
def line(r):
return f'· {r.name}:{r.summary or _plain(r.desc)}'
# 问候
if re.search(r'(你好|您好|hi|hello|在吗|嗨|早上好|晚上好)', q, re.I):
return ('你好,我是游戏助手 🤖 随时为你解答宇森世界的玩法。\n'
'可以问我:属性怎么算?怎么升级?货币有什么用?有哪些制度?')
# 属性(关键词覆盖攻击/物攻/法攻/防御/暴击等;穿透/韧性已移除)
_attr_re = re.search(r'(属性|攻击|物攻|法攻|防御|物防|法防|血量|生命|法力|速度|攻速|暴击|爆伤|命中|闪避|格挡|抗性|吸血|幸运)', q)
if _attr_re:
rules = list(rules_of('attribute_basic')) + list(rules_of('attribute_advanced'))
if rules:
head = '【属性规则】不同属性决定你的战斗表现,核心属性如下:'
return head + '\n' + '\n'.join(line(r) for r in rules[:12]) + '\n\n想深入了解某项,直接问我它的名字~'
# 等级
if re.search(r'(等级|升级|经验|世界等级|角色等级|怎么变强|变强)', q):
for key in ('world', 'character'):
rules = rules_of(key)
if rules:
c = Cat.search([('key', '=', key)], limit=1)
head = f'【{c.name}】'
lines = [f'· {r.name}(Lv{r.level}):{r.summary or ""}' for r in rules[:6]]
return head + '\n' + '\n'.join(l for l in lines if l.strip())
# 货币
if re.search(r'(货币|金币|钱|元宝|钻石|充值|兑换|经济)', q):
rules = rules_of('currency')
if rules:
head = '【货币规则】游戏内货币与获取/消耗方式:'
return head + '\n' + '\n'.join(line(r) for r in rules[:8])
# 制度 / 规则
if re.search(r'(制度|规定|规则|条款|公约|不能|禁止|惩罚|封号)', q):
rules = rules_of('system')
if rules:
head = '【制度说明】请遵守以下基本规则:'
return head + '\n' + '\n'.join(line(r) for r in rules[:8])
# 兜底:给出可问的方向
cats = Cat.search(
[('key', 'in', ['world', 'character', 'currency', 'attribute', 'system'])],
order='sequence')
tips = '、'.join(c.name for c in cats) if cats else '属性、等级、货币、制度'
return ('我是游戏助手,这个问题我还在学习中 🤔\n'
f'你可以问我这些方向:{tips}。\n例如:「攻击和物防怎么算?」「怎么升级最快?」')
class GameChatMessage(models.Model):
_name = 'game.chat.message'
+43 -1
View File
@@ -2,10 +2,52 @@
<odoo>
<!-- ============ 附件(Odoo 原生 ir.attachment) ============ -->
<!-- ============ 附件(Odoo 原生 ir.attachment)看板视图 ============ -->
<record id="yusen_attachment_kanban_view" model="ir.ui.view">
<field name="name">yusen.attachment.kanban</field>
<field name="model">ir.attachment</field>
<field name="arch" type="xml">
<kanban>
<field name="name"/>
<field name="description"/>
<field name="file_size"/>
<field name="mimetype"/>
<templates>
<t t-name="kanban-box">
<div class="oe_kanban_global_click oe_kanban_card d-flex flex-column p-2" style="width:160px;">
<div class="o_kanban_image mb-2 text-center" style="height:100px;line-height:100px;background:rgba(0,0,0,.06);border-radius:6px;overflow:hidden;font-size:40px;">
<t t-if="record.mimetype.raw_value and 'image' in record.mimetype.raw_value">🖼️</t>
<t t-else="">📎</t>
</div>
<div class="oe_kanban_details text-center">
<strong class="o_kanban_record_title d-block text-truncate" title="%s"><field name="name"/></strong>
<div class="text-muted small text-truncate mt-1" style="min-height:1.2em;">
<t t-if="record.description.raw_value"><field name="description"/></t>
<t t-else="">—</t>
</div>
<div class="small mt-1" style="color:#6c757d;">
<t t-if="record.file_size.raw_value &gt;= 1048576">
<t t-esc="(record.file_size.raw_value / 1048576).toFixed(2)"/> MB
</t>
<t t-elif="record.file_size.raw_value &gt;= 1024">
<t t-esc="(record.file_size.raw_value / 1024).toFixed(1)"/> KB
</t>
<t t-else="">
<t t-esc="record.file_size.raw_value"/> B
</t>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="action_yusen_attachment" model="ir.actions.act_window">
<field name="name">附件</field>
<field name="res_model">ir.attachment</field>
<field name="view_mode">list,form</field>
<field name="view_mode">kanban,list,form</field>
<field name="context">{'search_default_filter_website': 0}</field>
</record>
+63 -1
View File
@@ -1,13 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 世界聊天 · 顶级菜单 ============ -->
<record id="menu_game_chat_root" model="ir.ui.menu">
<field name="name">世界聊天</field>
<field name="sequence">10</field>
</record>
<!-- ============ 世界聊天 · 频道 ============ -->
<record id="action_game_chat_channel" model="ir.actions.act_window">
<field name="name">世界聊天</field>
<field name="name">频道</field>
<field name="res_model">game.chat.channel</field>
<field name="view_mode">list,form</field>
</record>
<record id="menu_game_chat_channel" model="ir.ui.menu">
<field name="name">频道</field>
<field name="parent_id" ref="menu_game_chat_root"/>
<field name="action" ref="action_game_chat_channel"/>
<field name="sequence">10</field>
</record>
<record id="view_game_chat_channel_list" model="ir.ui.view">
<field name="name">game.chat.channel.list</field>
<field name="model">game.chat.channel</field>
@@ -46,4 +59,53 @@
</field>
</record>
<!-- ============ 世界聊天 · 消息(后台记录与查看) ============ -->
<record id="action_game_chat_message" model="ir.actions.act_window">
<field name="name">聊天消息</field>
<field name="res_model">game.chat.message</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_group_channel': 1}</field>
</record>
<record id="view_game_chat_message_list" model="ir.ui.view">
<field name="name">game.chat.message.list</field>
<field name="model">game.chat.message</field>
<field name="arch" type="xml">
<list string="聊天消息" multi_edit="1">
<field name="channel_id"/>
<field name="author_name"/>
<field name="body" string="内容"/>
<field name="create_date"/>
</list>
</field>
</record>
<record id="view_game_chat_message_form" model="ir.ui.view">
<field name="name">game.chat.message.form</field>
<field name="model">game.chat.message</field>
<field name="arch" type="xml">
<form>
<group>
<field name="channel_id"/>
<field name="author_id"/>
<field name="author_name"/>
</group>
<group>
<field name="create_date"/>
</group>
<group string="内容">
<field name="body" nolabel="1"/>
</group>
</form>
</field>
</record>
<!-- 频道菜单下挂消息子项,方便后台同步查看聊天记录 -->
<record id="menu_game_chat_message" model="ir.ui.menu">
<field name="name">聊天消息</field>
<field name="parent_id" ref="menu_game_chat_root"/>
<field name="action" ref="action_game_chat_message"/>
<field name="sequence">20</field>
</record>
</odoo>
@@ -7,6 +7,14 @@
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_form"/>
<field name="arch" type="xml">
<!-- 隐藏原生「公开信息」(联系人) 和「HR 设置」(组织架构) 页签 -->
<xpath expr="//page[@name='public']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<xpath expr="//page[@name='hr_settings']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
<!-- 追加「游戏数据」页签(后续世界数据等关联也放这里) -->
<xpath expr="//notebook" position="inside">
<page string="游戏数据" name="game_data">
<group>
+5 -6
View File
@@ -442,15 +442,14 @@
</field>
</record>
<!-- ============ 玩家(res.users) ============ -->
<!-- 独立顶级菜单:用 res.users 直接管理玩家,比 hr.employee 语义更准;
view_mode 默认 list 起步,不进入"新建"表单。 -->
<!-- ============ 玩家(hr.employee) ============ -->
<!-- 直接用 hr.employee 管理玩家,点进去就是员工列表,无需经 res.users 中转 -->
<record id="action_yt_player" model="ir.actions.act_window">
<field name="name">玩家</field>
<field name="res_model">res.users</field>
<field name="res_model">hr.employee</field>
<field name="view_mode">list,form</field>
<field name="context">{'search_default_active': 1, 'search_default_share': 0}</field>
<field name="help">游戏玩家列表。点「玩家世界」菜单可按用户筛其下属的全部世界/地块/微观/角色/生物等数据。</field>
<field name="context">{'search_default_active': 1}</field>
<field name="help">游戏玩家(员工)列表。每位玩家对应一个 hr.employee,关联其 Odoo 登录账户。</field>
</record>
<record id="view_yt_player_search" model="ir.ui.view">
+128
View File
@@ -124,4 +124,132 @@
<field name="note"><![CDATA[至高货币,不可增发。总量由世界本源锚定,仅可通过特定事件获取。]]></field>
</record>
<!-- ====================================================================
属性规则(战斗 / 成长核心数值维度)
说明:前端「规则」页的「属性规则」页签从此处读取。
为支持「基础 / 高级」分区,属性拆为两个类别:
attribute_basic (基础属性,seq=50)
attribute_advanced(高级属性,seq=51)
前端会把两者合并到同一个「属性规则」页签内,分两组展示。
每条属性 = 一条 game.rule:
name = 属性中文名(前端按名映射 emoji 图标)
desc = 给玩家的说明(是什么、怎么影响战局)
summary = 设计师基准(成长来源 / 公式提示,后台可见)
物攻 / 法攻决定输出,防御类(防御 / 物防 / 法防)按伤害类型减伤,
其余为速度、暴击、爆伤、命中闪避等衍生维度,共同构成单位战力模型。
==================================================================== -->
<record id="rule_category_attribute_basic" model="game.rule.category">
<field name="key">attribute_basic</field>
<field name="name">基础属性</field>
<field name="sequence">50</field>
</record>
<record id="rule_category_attribute_advanced" model="game.rule.category">
<field name="key">attribute_advanced</field>
<field name="name">高级属性</field>
<field name="sequence">51</field>
</record>
<!-- ===== 基础属性 ===== -->
<!-- 生存 -->
<record id="attr_hp" model="game.rule">
<field name="category_id" ref="rule_category_attribute_basic"/><field name="level">0</field><field name="name">生命值</field>
<field name="desc"><![CDATA[单位存活的血量上限。受到伤害时优先扣除,归零则该单位倒下。随等级、装备与回复技能提升。]]></field>
<field name="summary"><![CDATA[基础值随等级成长,装备/天赋加成]]></field>
</record>
<record id="attr_mp" model="game.rule">
<field name="category_id" ref="rule_category_attribute_basic"/><field name="level">0</field><field name="name">法力值</field>
<field name="desc"><![CDATA[释放技能与法术的消耗资源。普攻不耗蓝,技能按强度扣蓝;蓝量不足无法施法,脱战自然回复。]]></field>
<field name="summary"><![CDATA[技能消耗资源,脱战回复]]></field>
</record>
<!-- 攻击 -->
<record id="attr_atk" model="game.rule">
<field name="category_id" ref="rule_category_attribute_basic"/><field name="level">0</field><field name="name">物攻</field>
<field name="desc"><![CDATA[物理输出能力,决定普通攻击与物理系技能的基础伤害。越高打出越疼。]]></field>
<field name="summary"><![CDATA[物理伤害基数]]></field>
</record>
<record id="attr_mat" model="game.rule">
<field name="category_id" ref="rule_category_attribute_basic"/><field name="level">0</field><field name="name">法攻</field>
<field name="desc"><![CDATA[法术输出能力,决定法术技能的基础伤害。与物攻相互独立,受法强装备加成。]]></field>
<field name="summary"><![CDATA[法术伤害基数]]></field>
</record>
<!-- 防御 -->
<record id="attr_def" model="game.rule">
<field name="category_id" ref="rule_category_attribute_basic"/><field name="level">0</field><field name="name">防御</field>
<field name="desc"><![CDATA[通用物理减伤能力,是对物理伤害的笼统防护,多数装备与体质提供。与「物防」同源,物防为其专精项。]]></field>
<field name="summary"><![CDATA[物理减伤(通用)]]></field>
</record>
<record id="attr_pdef" model="game.rule">
<field name="category_id" ref="rule_category_attribute_basic"/><field name="level">0</field><field name="name">物防</field>
<field name="desc"><![CDATA[针对物理伤害的专项减免,由护甲、盾牌等装备提供。面对物理系敌人时主要看此项。]]></field>
<field name="summary"><![CDATA[物理伤害减免]]></field>
</record>
<record id="attr_mdef" model="game.rule">
<field name="category_id" ref="rule_category_attribute_basic"/><field name="level">0</field><field name="name">法防</field>
<field name="desc"><![CDATA[针对法术伤害的专项减免,由法抗装备、抗性符文提供。面对法师 / 术士时主要看此项。]]></field>
<field name="summary"><![CDATA[法术伤害减免]]></field>
</record>
<!-- 节奏 -->
<record id="attr_spd" model="game.rule">
<field name="category_id" ref="rule_category_attribute_basic"/><field name="level">0</field><field name="name">速度</field>
<field name="desc"><![CDATA[行动先后顺序与出手频率。速度高者先手,回合制中更频繁行动;也影响部分先攻技能触发。]]></field>
<field name="summary"><![CDATA[先攻 / 行动顺序]]></field>
</record>
<record id="attr_aspd" model="game.rule">
<field name="category_id" ref="rule_category_attribute_basic"/><field name="level">0</field><field name="name">攻速</field>
<field name="desc"><![CDATA[两次攻击的间隔,越短出手越快、单位时间输出越高。影响普攻与部分技能频率。]]></field>
<field name="summary"><![CDATA[攻击间隔]]></field>
</record>
<!-- ===== 高级属性 ===== -->
<!-- 暴击 -->
<record id="attr_crt" model="game.rule">
<field name="category_id" ref="rule_category_attribute_advanced"/><field name="level">0</field><field name="name">暴击</field>
<field name="desc"><![CDATA[造成暴击的概率(百分比)。暴击时伤害按「爆伤」倍率提升。]]></field>
<field name="summary"><![CDATA[暴击率 %]]></field>
</record>
<record id="attr_crtdmg" model="game.rule">
<field name="category_id" ref="rule_category_attribute_advanced"/><field name="level">0</field><field name="name">爆伤</field>
<field name="desc"><![CDATA[暴击时的伤害倍率(默认 150%)。暴击率越高,堆此项收益越大。]]></field>
<field name="summary"><![CDATA[暴击倍率 %]]></field>
</record>
<!-- 命中 / 闪避 -->
<record id="attr_acc" model="game.rule">
<field name="category_id" ref="rule_category_attribute_advanced"/><field name="level">0</field><field name="name">命中</field>
<field name="desc"><![CDATA[攻击命中的概率。面对高闪避目标需足够命中,否则出现「未命中」。]]></field>
<field name="summary"><![CDATA[命中率 %]]></field>
</record>
<record id="attr_eva" model="game.rule">
<field name="category_id" ref="rule_category_attribute_advanced"/><field name="level">0</field><field name="name">闪避</field>
<field name="desc"><![CDATA[躲避攻击的概率。高闪避可让对手攻击落空,但受对方命中压制。]]></field>
<field name="summary"><![CDATA[闪避率 %]]></field>
</record>
<!-- 减伤 -->
<record id="attr_blk" model="game.rule">
<field name="category_id" ref="rule_category_attribute_advanced"/><field name="level">0</field><field name="name">格挡</field>
<field name="desc"><![CDATA[受击时触发格挡、按比例减免伤害的概率(需持盾 / 特定姿态)。格挡成功大幅减伤。]]></field>
<field name="summary"><![CDATA[格挡率 %]]></field>
</record>
<!-- 异常 / 续航 -->
<record id="attr_res" model="game.rule">
<field name="category_id" ref="rule_category_attribute_advanced"/><field name="level">0</field><field name="name">抗性</field>
<field name="desc"><![CDATA[对灼烧、冰冻、中毒等异常状态的抵抗概率。高抗性降低被控与持续掉血风险。]]></field>
<field name="summary"><![CDATA[异常抵抗 %]]></field>
</record>
<record id="attr_lifesteal" model="game.rule">
<field name="category_id" ref="rule_category_attribute_advanced"/><field name="level">0</field><field name="name">吸血</field>
<field name="desc"><![CDATA[造成伤害时按百分比回复自身生命。持续作战与单挑续航的关键属性。]]></field>
<field name="summary"><![CDATA[吸血 %]]></field>
</record>
<record id="attr_luck" model="game.rule">
<field name="category_id" ref="rule_category_attribute_advanced"/><field name="level">0</field><field name="name">幸运</field>
<field name="desc"><![CDATA[影响暴击、掉落与稀有事件触发概率的隐性维度。高幸运带来更多意外之喜与关键转折,是冒险与博弈中的变数之源。]]></field>
<field name="summary"><![CDATA[幸运值(影响暴击/掉落/稀有事件)]]></field>
</record>
</odoo>