Compare commits

..

No commits in common. "760cd517c27d295f98baa054cb9d63a7f015398b" and "467e260d8076edefc361df00353431268e2cf1ef" have entirely different histories.

37 changed files with 408 additions and 2188 deletions

View File

@ -1,10 +1,2 @@
from . import models from . import models
from . import controllers from . import controllers
def pre_init_hook(cr):
"""模块升级前清理旧的 game.currency 记录,避免模型删除时产生冲突。"""
cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name='game_currency'")
if cr.fetchone():
cr.execute("DELETE FROM game_currency")
cr.execute("DELETE FROM ir_model_data WHERE model='game.currency'")

View File

@ -1,24 +1,16 @@
{ {
'name': '宇森游戏基础数据', 'name': 'Yusen Game Base',
'summary': '宇森游戏基础数据:图鉴 / 规则 / 论坛 / 公告', 'summary': '宇森游戏基础数据:图鉴 / 规则 / 论坛 / 公告',
'version': '1.2', 'version': '1.1',
'category': 'Game', 'category': 'Game',
'sequence': 10, 'sequence': 10,
'depends': ['base'], 'depends': ['base'],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/game_base_data.xml', 'data/game_base_data.xml',
'views/codex_views.xml', 'views/game_base_views.xml',
'views/rule_category_views.xml',
'views/rule_views.xml',
'views/page_views.xml',
'views/forum_views.xml',
'views/announcement_views.xml',
'views/story_views.xml',
'views/menu.xml',
], ],
'application': True, 'application': True,
'license': 'LGPL-3', 'license': 'LGPL-3',
'pre_init_hook': 'pre_init_hook',
'description': '基于官网内容沉淀的游戏基础数据模型,供后台维护、前端经 /game/api/* 读取;并提供与 Odoo 用户打通的登录/注册接口。', 'description': '基于官网内容沉淀的游戏基础数据模型,供后台维护、前端经 /game/api/* 读取;并提供与 Odoo 用户打通的登录/注册接口。',
} }

View File

@ -63,86 +63,42 @@ class GameApiController(http.Controller):
@http.route('/game/api/rules', type='http', auth='public', methods=['GET'], csrf=False) @http.route('/game/api/rules', type='http', auth='public', methods=['GET'], csrf=False)
def rules(self, **kw): def rules(self, **kw):
"""规则 / 等级制度 / 货币:按类别分组。""" """规则 / 等级制度:按类别(type)分组。"""
Rule = request.env['game.rule'].sudo() model = request.env['game.rule'].sudo()
Category = request.env['game.rule.category'].sudo() type_labels = dict(model._fields['type'].selection)
cats = Category.search_read( recs = model.search_read([], ['id', 'type', 'level', 'name', 'desc'])
[('active', '=', True)], by_type = {}
['id', 'key', 'name', 'sequence'],
order='sequence, id'
)
cat_map = {c['id']: c for c in cats}
recs = Rule.search_read(
[],
['id', 'category_id', 'level', 'name', 'desc', 'rate', 'note'],
order='category_id, level'
)
by_cat = {}
for r in recs: for r in recs:
cid = r['category_id'][0] if r['category_id'] else None by_type.setdefault(r['type'], []).append(r)
if cid not in cat_map:
continue
by_cat.setdefault(cid, []).append(r)
groups = [] groups = []
for c in cats: for t in by_type:
items = by_cat.get(c['id'], [])
if not items:
continue
groups.append({ groups.append({
'key': c['key'], 'key': t,
'label': c['name'], 'label': type_labels.get(t, t),
'items': [{ 'items': [{
'id': it['id'], 'id': r['id'],
'level': it['level'], 'level': r['level'],
'name': it['name'], 'name': r['name'],
'desc': it['desc'] or '', 'desc': r['desc'] or '',
'rate': it.get('rate') or 1, } for r in by_type[t]],
'note': it.get('note') or '',
} for it in items],
}) })
return _json({'groups': groups}) return _json({'groups': groups})
@http.route('/game/api/currencies', type='http', auth='public', methods=['GET'], csrf=False) @http.route('/game/api/currencies', type='http', auth='public', methods=['GET'], csrf=False)
def currencies(self, **kw): def currencies(self, **kw):
"""货币等级:兼容旧接口,实际从 game.rule 按类别过滤。""" """货币等级:按 level 排序返回。"""
Rule = request.env['game.rule'].sudo() recs = request.env['game.currency'].sudo().search_read(
Category = request.env['game.rule.category'].sudo() [], ['id', 'level', 'name', 'desc', 'rate'], order='level asc'
currency_keys = ['currency', 'material', 'token', 'honor']
cats = Category.search_read(
[('active', '=', True), ('key', 'in', currency_keys)],
['id', 'key', 'name', 'sequence'],
order='sequence, id'
) )
cat_map = {c['id']: c for c in cats} return _json({
recs = Rule.search_read( 'items': [{
[('category_id.key', 'in', currency_keys)], 'id': r['id'],
['id', 'category_id', 'level', 'name', 'desc', 'note', 'rate'], 'level': r['level'],
order='category_id, level' 'name': r['name'],
) 'desc': r['desc'] or '',
by_type = {} 'rate': r['rate'] or 1,
for r in recs: } for r in recs]
cid = r['category_id'][0] if r['category_id'] else None })
if cid not in cat_map:
continue
by_type.setdefault(cid, []).append(r)
groups = []
for c in cats:
items = by_type.get(c['id'], [])
if not items:
continue
groups.append({
'key': c['key'],
'label': c['name'],
'items': [{
'id': it['id'],
'level': it['level'],
'name': it['name'],
'desc': it['desc'] or '',
'note': it.get('note') or '',
'rate': it.get('rate') or 1,
} for it in items],
})
return _json({'groups': groups})
@http.route('/game/api/pages', type='http', auth='public', methods=['GET'], csrf=False) @http.route('/game/api/pages', type='http', auth='public', methods=['GET'], csrf=False)
def pages(self, **kw): def pages(self, **kw):
@ -216,49 +172,6 @@ class GameApiController(http.Controller):
} for r in recs] } for r in recs]
return _json(out) return _json(out)
@http.route('/game/api/story', type='http', auth='public', methods=['GET'], csrf=False)
def story(self, **kw):
"""小说目录:返回 卷 -> 章节 结构,供前端左侧目录(TOC)使用。"""
Volume = request.env['game.story.volume'].sudo()
Chapter = request.env['game.story.chapter'].sudo()
vols = Volume.search_read([], ['id', 'name', 'sequence', 'intro'], order='sequence, id')
chapters = Chapter.search_read([], ['id', 'volume_id', 'name', 'sequence'],
order='volume_id, sequence, id')
by_vol = {}
for c in chapters:
vid = c['volume_id'][0] if c['volume_id'] else None
by_vol.setdefault(vid, []).append({
'id': c['id'],
'name': c['name'],
'sequence': c['sequence'],
})
out = []
for v in vols:
out.append({
'id': v['id'],
'name': v['name'],
'sequence': v['sequence'],
'intro': v.get('intro') or '',
'chapters': by_vol.get(v['id'], []),
})
return _json({'volumes': out})
@http.route('/game/api/story/chapter/<int:cid>', type='http', auth='public', methods=['GET'], csrf=False)
def story_chapter(self, cid, **kw):
"""单章正文(点击目录时按需拉取,避免一次性下发整本小说)。"""
rec = request.env['game.story.chapter'].sudo().search_read(
[('id', '=', cid)], ['id', 'volume_id', 'name', 'sequence', 'body_html'])
if not rec:
return _json({'error': '章节不存在'})
r = rec[0]
return _json({
'id': r['id'],
'volume_id': r['volume_id'][0] if r['volume_id'] else None,
'name': r['name'],
'sequence': r['sequence'],
'body_html': r['body_html'] or '',
})
# ============ 登录 / 注册 / 会话(与 Odoo res.users 打通) ============ # ============ 登录 / 注册 / 会话(与 Odoo res.users 打通) ============
@http.route('/game/api/login', type='http', auth='public', @http.route('/game/api/login', type='http', auth='public',

View File

@ -1,127 +1,93 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="0"> <odoo noupdate="0">
<!-- ============ 规则类别(后台可配置) ============ -->
<record id="rule_category_world" model="game.rule.category">
<field name="key">world</field>
<field name="name">世界等级</field>
<field name="sequence">10</field>
</record>
<record id="rule_category_faction" model="game.rule.category">
<field name="key">faction</field>
<field name="name">势力等级</field>
<field name="sequence">20</field>
</record>
<record id="rule_category_character" model="game.rule.category">
<field name="key">character</field>
<field name="name">人物角色等级</field>
<field name="sequence">30</field>
</record>
<record id="rule_category_currency" model="game.rule.category">
<field name="key">currency</field>
<field name="name">货币等级</field>
<field name="sequence">40</field>
</record>
<!-- ============ 规则 / 等级制度(基于官网) ============ --> <!-- ============ 规则 / 等级制度(基于官网) ============ -->
<!-- 世界等级 · 五阶段 --> <!-- 世界等级 · 五阶段 -->
<record id="rule_world_1" model="game.rule"> <record id="rule_world_1" model="game.rule">
<field name="category_id" ref="rule_category_world"/><field name="level">1</field><field name="name">凡墟</field> <field name="type">world</field><field name="level">1</field><field name="name">凡墟</field>
<field name="desc"><![CDATA[万物生灵尚处蒙昧凡土,天地灵气稀薄,各族散居荒野,无族群联盟与政权,只求狩猎采集求生,一切秩序遵循自然法则。]]></field> <field name="desc"><![CDATA[万物生灵尚处蒙昧凡土,天地灵气稀薄,各族散居荒野,无族群联盟与政权,只求狩猎采集求生,一切秩序遵循自然法则。]]></field>
</record> </record>
<record id="rule_world_2" model="game.rule"> <record id="rule_world_2" model="game.rule">
<field name="category_id" ref="rule_category_world"/><field name="level">2</field><field name="name">灵启</field> <field name="type">world</field><field name="level">2</field><field name="name">灵启</field>
<field name="desc"><![CDATA[天地灵气复苏觉醒,各族聚拢形成聚落,诞生劳作分工、屋舍营建与物物交换,不同族群率先凝聚部族,世间初代势力自此诞生。]]></field> <field name="desc"><![CDATA[天地灵气复苏觉醒,各族聚拢形成聚落,诞生劳作分工、屋舍营建与物物交换,不同族群率先凝聚部族,世间初代势力自此诞生。]]></field>
</record> </record>
<record id="rule_world_3" model="game.rule"> <record id="rule_world_3" model="game.rule">
<field name="category_id" ref="rule_category_world"/><field name="level">3</field><field name="name">玄界</field> <field name="type">world</field><field name="level">3</field><field name="name">玄界</field>
<field name="desc"><![CDATA[各族部落兼并融合,广袤大地形成多元族群共治的完整疆域,征伐、道统、造物三大发展路线并行;各族大能、上古秘闻等传奇事件不断现世,玄荒文明迎来鼎盛割据时代。]]></field> <field name="desc"><![CDATA[各族部落兼并融合,广袤大地形成多元族群共治的完整疆域,征伐、道统、造物三大发展路线并行;各族大能、上古秘闻等传奇事件不断现世,玄荒文明迎来鼎盛割据时代。]]></field>
</record> </record>
<record id="rule_world_4" model="game.rule"> <record id="rule_world_4" model="game.rule">
<field name="category_id" ref="rule_category_world"/><field name="level">4</field><field name="name">穹冥</field> <field name="type">world</field><field name="level">4</field><field name="name">穹冥</field>
<field name="desc"><![CDATA[生灵突破地表桎梏,打通地底深渊与浩瀚星空,天地上下各界尽数纳入族群管辖;各类上古神器、本命法器完整解锁,文明疆域遍布天地穹苍。]]></field> <field name="desc"><![CDATA[生灵突破地表桎梏,打通地底深渊与浩瀚星空,天地上下各界尽数纳入族群管辖;各类上古神器、本命法器完整解锁,文明疆域遍布天地穹苍。]]></field>
</record> </record>
<record id="rule_world_5" model="game.rule"> <record id="rule_world_5" model="game.rule">
<field name="category_id" ref="rule_category_world"/><field name="level">5</field><field name="name">溯元</field> <field name="type">world</field><field name="level">5</field><field name="name">溯元</field>
<field name="desc"><![CDATA[各族顶尖文明勘破这片天地的诞生本源,挣脱本世界规则束缚,可踏上飞升之路、往复轮回,前往更高维度开启全新文明演化。]]></field> <field name="desc"><![CDATA[各族顶尖文明勘破这片天地的诞生本源,挣脱本世界规则束缚,可踏上飞升之路、往复轮回,前往更高维度开启全新文明演化。]]></field>
</record> </record>
<!-- 势力等级 · Lv.1-6 --> <!-- 势力等级 · Lv.1-6 -->
<record id="rule_faction_1" model="game.rule"> <record id="rule_faction_1" model="game.rule">
<field name="category_id" ref="rule_category_faction"/><field name="level">1</field><field name="name">墟族</field> <field name="type">faction</field><field name="level">1</field><field name="name">墟族</field>
<field name="desc"><![CDATA[血缘小群体,蛮荒无制度。]]></field> <field name="desc"><![CDATA[血缘小群体,蛮荒无制度。]]></field>
</record> </record>
<record id="rule_faction_2" model="game.rule"> <record id="rule_faction_2" model="game.rule">
<field name="category_id" ref="rule_category_faction"/><field name="level">2</field><field name="name">灵聚</field> <field name="type">faction</field><field name="level">2</field><field name="name">灵聚</field>
<field name="desc"><![CDATA[灵气聚落联盟,初步治理与贸易。]]></field> <field name="desc"><![CDATA[灵气聚落联盟,初步治理与贸易。]]></field>
</record> </record>
<record id="rule_faction_3" model="game.rule"> <record id="rule_faction_3" model="game.rule">
<field name="category_id" ref="rule_category_faction"/><field name="level">3</field><field name="name">玄邑</field> <field name="type">faction</field><field name="level">3</field><field name="name">玄邑</field>
<field name="desc"><![CDATA[疆域整合,军法、道规体系完善。]]></field> <field name="desc"><![CDATA[疆域整合,军法、道规体系完善。]]></field>
</record> </record>
<record id="rule_faction_4" model="game.rule"> <record id="rule_faction_4" model="game.rule">
<field name="category_id" ref="rule_category_faction"/><field name="level">4</field><field name="name">冥朝</field> <field name="type">faction</field><field name="level">4</field><field name="name">冥朝</field>
<field name="desc"><![CDATA[一统大地,掌控地下浅层资源调度。]]></field> <field name="desc"><![CDATA[一统大地,掌控地下浅层资源调度。]]></field>
</record> </record>
<record id="rule_faction_5" model="game.rule"> <record id="rule_faction_5" model="game.rule">
<field name="category_id" ref="rule_category_faction"/><field name="level">5</field><field name="name">穹统</field> <field name="type">faction</field><field name="level">5</field><field name="name">穹统</field>
<field name="desc"><![CDATA[天地大陆霸主,穿梭星空深渊作战。]]></field> <field name="desc"><![CDATA[天地大陆霸主,穿梭星空深渊作战。]]></field>
</record> </record>
<record id="rule_faction_6" model="game.rule"> <record id="rule_faction_6" model="game.rule">
<field name="category_id" ref="rule_category_faction"/><field name="level">6</field><field name="name">溯盟</field> <field name="type">faction</field><field name="level">6</field><field name="name">溯盟</field>
<field name="desc"><![CDATA[多维文明联合体,触及世界本源博弈。]]></field> <field name="desc"><![CDATA[多维文明联合体,触及世界本源博弈。]]></field>
</record> </record>
<!-- 人物角色等级 · Lv.1-12 --> <!-- 人物角色等级 · Lv.1-12 -->
<record id="rule_char_1" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">1</field><field name="name">萌新</field><field name="desc"><![CDATA[初入世界的无名者。]]></field></record> <record id="rule_char_1" model="game.rule"><field name="type">character</field><field name="level">1</field><field name="name">萌新</field><field name="desc"><![CDATA[初入世界的无名者。]]></field></record>
<record id="rule_char_2" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">2</field><field name="name">学徒</field><field name="desc"><![CDATA[掌握基础技艺。]]></field></record> <record id="rule_char_2" model="game.rule"><field name="type">character</field><field name="level">2</field><field name="name">学徒</field><field name="desc"><![CDATA[掌握基础技艺。]]></field></record>
<record id="rule_char_3" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">3</field><field name="name">熟手</field><field name="desc"><![CDATA[可独立完成任务。]]></field></record> <record id="rule_char_3" model="game.rule"><field name="type">character</field><field name="level">3</field><field name="name">熟手</field><field name="desc"><![CDATA[可独立完成任务。]]></field></record>
<record id="rule_char_4" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">4</field><field name="name">好手</field><field name="desc"><![CDATA[在某领域崭露头角。]]></field></record> <record id="rule_char_4" model="game.rule"><field name="type">character</field><field name="level">4</field><field name="name">好手</field><field name="desc"><![CDATA[在某领域崭露头角。]]></field></record>
<record id="rule_char_5" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">5</field><field name="name">精英</field><field name="desc"><![CDATA[群体中的中坚。]]></field></record> <record id="rule_char_5" model="game.rule"><field name="type">character</field><field name="level">5</field><field name="name">精英</field><field name="desc"><![CDATA[群体中的中坚。]]></field></record>
<record id="rule_char_6" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">6</field><field name="name">大师</field><field name="desc"><![CDATA[技艺臻于化境。]]></field></record> <record id="rule_char_6" model="game.rule"><field name="type">character</field><field name="level">6</field><field name="name">大师</field><field name="desc"><![CDATA[技艺臻于化境。]]></field></record>
<record id="rule_char_7" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">7</field><field name="name">宗匠</field><field name="desc"><![CDATA[开宗立派的人物。]]></field></record> <record id="rule_char_7" model="game.rule"><field name="type">character</field><field name="level">7</field><field name="name">宗匠</field><field name="desc"><![CDATA[开宗立派的人物。]]></field></record>
<record id="rule_char_8" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">8</field><field name="name">传奇</field><field name="desc"><![CDATA[事迹被载入编年史。]]></field></record> <record id="rule_char_8" model="game.rule"><field name="type">character</field><field name="level">8</field><field name="name">传奇</field><field name="desc"><![CDATA[事迹被载入编年史。]]></field></record>
<record id="rule_char_9" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">9</field><field name="name">神话</field><field name="desc"><![CDATA[近乎传说的存在。]]></field></record> <record id="rule_char_9" model="game.rule"><field name="type">character</field><field name="level">9</field><field name="name">神话</field><field name="desc"><![CDATA[近乎传说的存在。]]></field></record>
<record id="rule_char_10" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">10</field><field name="name">半神</field><field name="desc"><![CDATA[触及世界法则。]]></field></record> <record id="rule_char_10" model="game.rule"><field name="type">character</field><field name="level">10</field><field name="name">半神</field><field name="desc"><![CDATA[触及世界法则。]]></field></record>
<record id="rule_char_11" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">11</field><field name="name">圣者</field><field name="desc"><![CDATA[被信仰托举的意志。]]></field></record> <record id="rule_char_11" model="game.rule"><field name="type">character</field><field name="level">11</field><field name="name">圣者</field><field name="desc"><![CDATA[被信仰托举的意志。]]></field></record>
<record id="rule_char_12" model="game.rule"><field name="category_id" ref="rule_category_character"/><field name="level">12</field><field name="name">永恒</field><field name="desc"><![CDATA[超越生死的世界锚点。]]></field></record> <record id="rule_char_12" model="game.rule"><field name="type">character</field><field name="level">12</field><field name="name">永恒</field><field name="desc"><![CDATA[超越生死的世界锚点。]]></field></record>
<!-- ============ 货币等级 · Lv.1-6合并进 game.rule ============ --> <!-- ============ 货币等级 · Lv.1-6 ============ -->
<record id="currency_1" model="game.rule"> <record id="currency_1" model="game.currency">
<field name="category_id" ref="rule_category_currency"/><field name="level">1</field><field name="name">墟铢</field> <field name="level">1</field><field name="name">墟铢</field><field name="rate">100</field>
<field name="rate">100</field>
<field name="desc"><![CDATA[凡俗通用零钱以贝壳、骨片、碎石铸成。100 墟铢 = 1 灵币。]]></field> <field name="desc"><![CDATA[凡俗通用零钱以贝壳、骨片、碎石铸成。100 墟铢 = 1 灵币。]]></field>
<field name="note"><![CDATA[发行方:各地聚落自治铸坊。流通范围:地表层全域通用,地下层部分区域接受。]]></field>
</record> </record>
<record id="currency_2" model="game.rule"> <record id="currency_2" model="game.currency">
<field name="category_id" ref="rule_category_currency"/><field name="level">2</field><field name="name">灵币</field> <field name="level">2</field><field name="name">灵币</field><field name="rate">100</field>
<field name="rate">100</field>
<field name="desc"><![CDATA[蕴含微量灵气的金属铸币聚落以上势力日常使用。100 灵币 = 1 玄锭。]]></field> <field name="desc"><![CDATA[蕴含微量灵气的金属铸币聚落以上势力日常使用。100 灵币 = 1 玄锭。]]></field>
<field name="note"><![CDATA[发行方:玄邑级势力统一铸造。流通范围:凡墟/灵启/玄界三层硬通货。]]></field>
</record> </record>
<record id="currency_3" model="game.rule"> <record id="currency_3" model="game.currency">
<field name="category_id" ref="rule_category_currency"/><field name="level">3</field><field name="name">玄锭</field> <field name="level">3</field><field name="name">玄锭</field><field name="rate">100</field>
<field name="rate">100</field>
<field name="desc"><![CDATA[刻有玄纹的稀有锭块用于高价值交易与功法图纸。100 玄锭 = 1 冥玉。]]></field> <field name="desc"><![CDATA[刻有玄纹的稀有锭块用于高价值交易与功法图纸。100 玄锭 = 1 冥玉。]]></field>
<field name="note"><![CDATA[发行方:冥朝中央造币司。流通范围:跨层贸易结算单位。]]></field>
</record> </record>
<record id="currency_4" model="game.rule"> <record id="currency_4" model="game.currency">
<field name="category_id" ref="rule_category_currency"/><field name="level">4</field><field name="name">冥玉</field> <field name="level">4</field><field name="name">冥玉</field><field name="rate">100</field>
<field name="rate">100</field>
<field name="desc"><![CDATA[产自地底深渊的幽暗玉石蕴含浓郁能量。100 冥玉 = 1 穹鎏。]]></field> <field name="desc"><![CDATA[产自地底深渊的幽暗玉石蕴含浓郁能量。100 冥玉 = 1 穹鎏。]]></field>
<field name="note"><![CDATA[产地:地下层深渊矿脉。流通范围:穹冥时代以上跨层交易核心货币。]]></field>
</record> </record>
<record id="currency_5" model="game.rule"> <record id="currency_5" model="game.currency">
<field name="category_id" ref="rule_category_currency"/><field name="level">5</field><field name="name">穹鎏</field> <field name="level">5</field><field name="name">穹鎏</field><field name="rate">100</field>
<field name="rate">100</field>
<field name="desc"><![CDATA[来自星空矿脉的稀有鎏金晶体跨层贸易硬通货。100 穹鎏 = 1 元玺。]]></field> <field name="desc"><![CDATA[来自星空矿脉的稀有鎏金晶体跨层贸易硬通货。100 穹鎏 = 1 元玺。]]></field>
<field name="note"><![CDATA[产地:星空层陨石带。流通范围:溯盟级势力间储备货币。]]></field>
</record> </record>
<record id="currency_6" model="game.rule"> <record id="currency_6" model="game.currency">
<field name="category_id" ref="rule_category_currency"/><field name="level">6</field><field name="name">元玺</field> <field name="level">6</field><field name="name">元玺</field><field name="rate">1</field>
<field name="rate">1</field>
<field name="desc"><![CDATA[追溯世界本源凝聚而成的至高货币,可兑换神器、传说事件与维度权限。]]></field> <field name="desc"><![CDATA[追溯世界本源凝聚而成的至高货币,可兑换神器、传说事件与维度权限。]]></field>
<field name="note"><![CDATA[至高货币,不可增发。总量由世界本源锚定,仅可通过特定事件获取。]]></field>
</record> </record>
<!-- ============ 图鉴样例(每类 1 条,供用户参考扩展;按分类+子级分组) ============ --> <!-- ============ 图鉴样例(每类 1 条,供用户参考扩展;按分类+子级分组) ============ -->
@ -217,32 +183,6 @@
<field name="desc">每个 NPC 都有独立的天赋、性格与成长轨迹。英雄在历练中觉醒,传奇在时间中自发生成,无人是棋子。</field> <field name="desc">每个 NPC 都有独立的天赋、性格与成长轨迹。英雄在历练中觉醒,传奇在时间中自发生成,无人是棋子。</field>
</record> </record>
<!-- 介绍 -->
<record id="page_intro" model="game.page">
<field name="key">intro</field>
<field name="title">关于宇森</field>
<field name="subtitle">ABOUT YUSEN</field>
<field name="sequence">15</field>
<field name="body_html"><![CDATA[<p>宇森,是一颗会自己呼吸的种子。</p>
<p>你播种的不是一个角色,而是一个<b style="color:#fff">世界</b>——从地心熔岩到陨石星空,五层垂直切面层层相托,文明在其中自发地生长、分裂、兼并、飞升。没有脚本替你演绎剧情,没有关卡替你定义胜利,每一次开局都是一部独一无二的编年史。</p>
<p>我们相信,最好的模拟不在于你操控了多少,而在于你<b style="color:#fff">见证</b>了多少。所以宇森把「玩家」重新定义为<b style="color:var(--gold)">园丁</b>:你不能命令谁,却可以改变土壤、调节灵气、在关键节点轻轻推一把。世界的走向,由无数个体的选择汇聚而成,而你只是其中一股温柔的力。</p>
<p style="margin-top:22px;color:var(--gold);font-style:italic;">往下翻,看这颗种子如何长成一片森林。</p>]]></field>
</record>
<record id="page_intro_item_1" model="game.page.item">
<field name="page_id" ref="page_intro"/>
<field name="sequence">10</field>
<field name="icon">🌍</field>
<field name="title">一颗会呼吸的世界</field>
<field name="desc">五层垂直世界,文明自发演化,没有固定剧情与通关条件。</field>
</record>
<record id="page_intro_item_2" model="game.page.item">
<field name="page_id" ref="page_intro"/>
<field name="sequence">20</field>
<field name="icon">🧑‍🌾</field>
<field name="title">你是园丁,不是上帝</field>
<field name="desc">改变土壤与灵气,在关键节点轻推一把,而非发号施令。</field>
</record>
<!-- 背景 --> <!-- 背景 -->
<record id="page_background" model="game.page"> <record id="page_background" model="game.page">
<field name="key">background</field> <field name="key">background</field>
@ -293,48 +233,6 @@
<field name="desc">本源之地 · 元玺凝聚</field> <field name="desc">本源之地 · 元玺凝聚</field>
</record> </record>
<!-- 说明 -->
<record id="page_notes" model="game.page">
<field name="key">notes</field>
<field name="title">制度说明</field>
<field name="subtitle">HOW THE WORLD WORKS</field>
<field name="sequence">25</field>
<field name="body_html"><![CDATA[<p>宇森的世界由三套相互独立的等级体系共同驱动,它们彼此加成,又彼此制约:</p>
<p><b style="color:#fff">世界等级</b>是舞台的上限——它决定这片土地上「能发生什么」。从凡墟到溯元,每一级跃迁都会解锁更深的内容层(更深的地下、更远的星空)与更厚的事件池。</p>
<p><b style="color:#fff">势力等级</b>是群体的进度——它决定文明「能调动什么」。聚落、联盟到多维联合体,等级越高,可调度资源与外交权重越大。</p>
<p><b style="color:#fff">角色等级</b>是个体的成长——它决定一个生命「能做成什么」。从无名萌新到超越生死的永恒,英雄在历练中觉醒。</p>
<p>而贯穿三者的,是<b style="color:var(--gold)">六档货币</b>:从凡俗墟铢到本源元玺,百进制兑换构成完整经济闭环,决定文明「能交换什么」。</p>
<p>园丁的核心策略,便是在这四方之间分配有限的引导资源,让文明自洽地向上演化——每一级提升,世界都因此越活越厚。</p>]]></field>
</record>
<record id="page_notes_item_1" model="game.page.item">
<field name="page_id" ref="page_notes"/>
<field name="sequence">10</field>
<field name="icon">🌐</field>
<field name="title">世界等级</field>
<field name="desc">舞台上限:决定能发生什么。凡墟 → 灵启 → 玄界 → 穹冥 → 溯元。</field>
</record>
<record id="page_notes_item_2" model="game.page.item">
<field name="page_id" ref="page_notes"/>
<field name="sequence">20</field>
<field name="icon">🏛️</field>
<field name="title">势力等级</field>
<field name="desc">群体进度:决定能调动什么。墟族 → 灵聚 → 玄邑 → 冥朝 → 穹统 → 溯盟。</field>
</record>
<record id="page_notes_item_3" model="game.page.item">
<field name="page_id" ref="page_notes"/>
<field name="sequence">30</field>
<field name="icon">⚔️</field>
<field name="title">角色等级</field>
<field name="desc">个体成长:决定能做成什么。萌新 → …… → 永恒,十二阶序。</field>
</record>
<record id="page_notes_item_4" model="game.page.item">
<field name="page_id" ref="page_notes"/>
<field name="sequence">40</field>
<field name="icon">💰</field>
<field name="title">六档货币</field>
<field name="desc">经济闭环:决定能交换什么。墟铢 → 灵币 → 玄锭 → 冥玉 → 穹鎏 → 元玺。</field>
</record>
<!-- 致玩家 --> <!-- 致玩家 -->
<record id="page_letter" model="game.page"> <record id="page_letter" model="game.page">
<field name="key">letter</field> <field name="key">letter</field>
@ -351,112 +249,4 @@
<p>欢迎播种。</p>]]></field> <p>欢迎播种。</p>]]></field>
</record> </record>
<!-- ============ 论坛种子(登录后可见) ============ -->
<record id="thread_welcome" model="game.forum.thread">
<field name="name">【公告】宇森官网第一阶段上线,欢迎园丁们入驻</field>
<field name="category">discuss</field>
<field name="author_name">官方</field>
<field name="pinned">1</field>
<field name="body_html"><![CDATA[<p>宇森官网第一阶段上线:介绍 / 规则 / 图鉴 / 论坛 / 公告 / 剧情 六块,单页切换呈现。欢迎园丁们入驻,一起把这个世界养活。</p>]]></field>
</record>
<record id="thread_newbie" model="game.forum.thread">
<field name="name">新手向:园丁视角的三种开局思路</field>
<field name="category">strategy</field>
<field name="author_name">行者</field>
<field name="body_html"><![CDATA[<p>① 静观其变:前期少干预,先把灵气浓度调匀;② 重点扶持:选一个族群定向加灵气,加速聚落成型;③ 制造意外:故意制造资源稀缺,观察文明如何应对。你更偏向哪种?</p>]]></field>
</record>
<record id="thread_lore" model="game.forum.thread">
<field name="name">世界观考据:五层世界是怎么来的</field>
<field name="category">lore</field>
<field name="author_name">雾岛</field>
<field name="body_html"><![CDATA[<p>从地心熔岩到陨石星空,五层世界是灵气自下而上攀爬的切面。凡墟→灵启→玄界→穹冥→溯元,每一层都有独立的生态与事件池,文明则是被这五层同时托举的。</p>]]></field>
</record>
<record id="thread_showcase" model="game.forum.thread">
<field name="name">手绘了我的主世界浮空岛,求轻喷</field>
<field name="category">showcase</field>
<field name="author_name">青柠</field>
<field name="body_html"><![CDATA[<p>肝了三个晚上,把我主世界那座浮空岛画出来了,云朵地基块 + 中央灵泉,欢迎来捞图也欢迎拍砖。</p>]]></field>
</record>
<record id="thread_bug" model="game.forum.thread">
<field name="name">反馈:飞入微观时偶现卡顿</field>
<field name="category">bug</field>
<field name="author_name">K</field>
<field name="body_html"><![CDATA[<p>进入微观沙盘的前一两秒偶尔会掉帧,退出再进就正常了。设备是集显笔记本,疑似首帧资源加载没预载,供开发组参考。</p>]]></field>
</record>
<!-- ============ 公告补充 ============ -->
<record id="ann_roadmap" model="game.announcement">
<field name="name">【前瞻】微观沙盘与剧情站即将开放</field>
<field name="date">2026-07-12</field>
<field name="priority">important</field>
<field name="body_html"><![CDATA[<p>下一阶段将开放「微观沙盘」与「剧情站」。前者让你亲手摆放六边形地块、引导文明生长;后者将连载《园丁手记》系列小说,记录这个世界的来龙去脉。</p>]]></field>
</record>
<record id="ann_convention" model="game.announcement">
<field name="name">【须知】社区公约与发帖规范</field>
<field name="date">2026-07-15</field>
<field name="priority">normal</field>
<field name="body_html"><![CDATA[<p>论坛仅对登录用户开放。请友善交流、尊重原创;同人创作欢迎标注「二创」,攻略与考据可自由转载,转载请注明出处。</p>]]></field>
</record>
<record id="ann_story" model="game.announcement">
<field name="name">【公告】剧情站上线:《园丁手记·凡墟篇》</field>
<field name="date">2026-07-20</field>
<field name="priority">important</field>
<field name="body_html"><![CDATA[<p>剧情站正式上线!首卷《园丁手记·凡墟篇》已开放阅读,记录灵气初醒、第一株苗与聚落第一缕光的蒙昧年代。前往导航「剧情」即可翻阅。</p>]]></field>
</record>
<!-- ============ 剧情(小说)种子 ============ -->
<record id="story_vol_fanxu" model="game.story.volume">
<field name="name">第一卷 · 园丁手记·凡墟篇</field>
<field name="sequence">10</field>
<field name="intro"><![CDATA[<p>凡墟篇·园丁手记。灵气初醒的蒙昧年代,文明的种子刚刚落进泥土。</p>]]></field>
</record>
<record id="story_ch_fx_1" model="game.story.chapter">
<field name="volume_id" ref="story_vol_fanxu"/>
<field name="sequence">10</field>
<field name="name">楔子 · 灵气初醒</field>
<field name="body_html"><![CDATA[<p>天地初开的时候,没有名字。</p>
<p>只有一团混沌的灵气,从地心最深处艰难地往上爬——它穿过滚烫的岩浆,穿过沉默的岩层,在某个谁也说不清的时刻,第一次触到了冰冷的地表。</p>
<p>那一刻,凡土醒了。</p>
<p>后来的人把它叫做「灵气初醒」。但在当时,没有谁记得这一刻。只有风,把第一缕微弱的生机,吹向了荒野里零散的族群。</p>]]></field>
</record>
<record id="story_ch_fx_2" model="game.story.chapter">
<field name="volume_id" ref="story_vol_fanxu"/>
<field name="sequence">20</field>
<field name="name">第一章 · 第一株苗</field>
<field name="body_html"><![CDATA[<p>园丁第一次伸手,是在一片荒芜的斜坡上。</p>
<p>他什么也没做,只是把掌心的灵气,轻轻按进了一粒埋在石缝里的种子。没有咒语,没有仪式——只是给予,然后等待。</p>
<p>三天后,一株嫩绿的苗破土而出。它不知道自己被谁照料,也不在乎。它只是生长,像这个世界本该有的样子。</p>
<p>园丁看着那点绿色,忽然明白了自己该做的事:不是统治,不是创造,而是——让生长发生。</p>]]></field>
</record>
<record id="story_ch_fx_3" model="game.story.chapter">
<field name="volume_id" ref="story_vol_fanxu"/>
<field name="sequence">30</field>
<field name="name">第二章 · 聚落的第一缕光</field>
<field name="body_html"><![CDATA[<p>苗活下来之后,荒野里有了第二个、第三个生命。</p>
<p>零散的族群循着灵气的痕迹聚拢。他们不再只是狩猎采集,开始用碎石垒出简陋的屋舍,用兽骨交换 surplus 的猎物。第一缕属于「文明」的光,在凡墟的夜色里亮起。</p>
<p>园丁没有现身。他只是把灵气的浓度,往聚落的方向,悄悄调浓了半分。</p>]]></field>
</record>
<record id="story_vol_lingqi" model="game.story.volume">
<field name="name">第二卷 · 园丁手记·灵启篇</field>
<field name="sequence">20</field>
<field name="intro"><![CDATA[<p>灵启篇·园丁手记。当聚落连成部族,争与和同时降临。</p>]]></field>
</record>
<record id="story_ch_lq_1" model="game.story.chapter">
<field name="volume_id" ref="story_vol_lingqi"/>
<field name="sequence">10</field>
<field name="name">第一章 · 部族初成</field>
<field name="body_html"><![CDATA[<p>灵气浓到一定程度,聚落开始兼并。</p>
<p>三个相邻的族群,在一场没有流血的议事之后,决定共用一口深井。这是凡墟历史上第一次「联盟」——没有王,只有约定。</p>
<p>园丁在云端记下了这一笔。他知道,从今往后,世界不再只是一群野生的人,而是一个会自己谈判、自己妥协的活物。</p>]]></field>
</record>
<record id="story_ch_lq_2" model="game.story.chapter">
<field name="volume_id" ref="story_vol_lingqi"/>
<field name="sequence">20</field>
<field name="name">第二章 · 交易与争端</field>
<field name="body_html"><![CDATA[<p>联盟带来了繁荣,也带来了第一场争端。</p>
<p>东边的墟族说井水该归他们,西边的人不肯。争执了七天,最后是园丁在不经意间,让井边多长出一片可食用的苔藓——谁都能采,谁都不必独占。</p>
<p>争端平息的方式,从来不在胜负,而在有没有第三种选择。这是园丁学到的第二课。</p>]]></field>
</record>
</odoo> </odoo>

View File

@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
def migrate(cr, version):
"""升级到 1.2:清理旧的 game.currency 记录与外部 ID避免合并到 game.rule 时冲突。"""
# 先删数据表记录,再删对应 ir.model.data 行
cr.execute("SELECT 1 FROM information_schema.tables WHERE table_name='game_currency'")
if cr.fetchone():
cr.execute("DELETE FROM game_currency")
cr.execute("DELETE FROM ir_model_data WHERE model='game.currency'")

View File

@ -1,7 +1,6 @@
from . import codex from . import codex
from . import forum from . import forum
from . import announcement from . import announcement
from . import rule_category
from . import rule from . import rule
from . import currency
from . import page from . import page
from . import story

View File

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class GameCurrency(models.Model):
_name = 'game.currency'
_description = '游戏货币等级'
_order = 'level asc'
level = fields.Integer(string='等级', required=True)
name = fields.Char(string='名称', required=True)
desc = fields.Text(string='描述')
rate = fields.Integer(
string='兑换比例',
default=1,
help='多少单位本货币可兑换 1 单位下一等级货币。例如 100 墟铢 = 1 灵币,则墟铢的 rate 为 100。'
)
@api.model
def convert(self, amount, from_level, to_level):
"""按等级汇率将 amount 从 from_level 换算为 to_level 货币。
汇率方向低等级 高等级 为除以 rate高等级 低等级 为乘以 rate
最高等级货币元玺 rate 固定为 1仅作占位
"""
if from_level == to_level:
return amount
currencies = self.search_read([], ['level', 'rate'], order='level asc')
rate_map = {c['level']: c['rate'] or 1 for c in currencies}
if from_level < to_level:
# 低等级 → 高等级:逐级除以兑换比例
result = amount
for lv in range(from_level, to_level):
result = result / rate_map.get(lv, 1)
return result
else:
# 高等级 → 低等级:逐级乘以兑换比例
result = amount
for lv in range(to_level, from_level):
result = result * rate_map.get(lv, 1)
return result

View File

@ -1,64 +1,19 @@
# -*- coding: utf-8 -*- from odoo import models, fields
from odoo import models, fields, api
class GameRule(models.Model): class GameRule(models.Model):
_name = 'game.rule' _name = 'game.rule'
_description = '规则 / 等级制度 / 货币' _description = '规则 / 等级制度'
_rec_name = 'name' _rec_name = 'name'
_order = 'category_id, level' _order = 'type, level'
category_id = fields.Many2one( TYPE = [
'game.rule.category', ('world', '世界等级'),
string='类别', ('faction', '势力等级'),
required=True, ('character', '人物角色等级'),
index=True, ]
ondelete='restrict',
) type = fields.Selection(TYPE, string='类别', required=True, index=True)
level = fields.Integer( level = fields.Integer(string='等级', index=True)
string='等级',
index=True,
aggregator=False, # 分组时不累计
)
name = fields.Char(string='名称', required=True) name = fields.Char(string='名称', required=True)
desc = fields.Html(string='说明') desc = fields.Html(string='说明')
rate = fields.Integer(
string='兑换比例',
default=1,
help='多少单位本货币可兑换 1 单位下一等级货币。例如 100 墟铢 = 1 灵币,则墟铢的 rate 为 100。仅货币类有效。',
)
note = fields.Text(
string='备注',
help='额外说明,仅后台可见(如发行背景、流通范围等)。',
)
@api.model
def convert(self, amount, from_level, to_level, category_key='currency'):
"""按等级汇率将 amount 从 from_level 换算为 to_level 货币。
默认只在货币类别内换算汇率方向低等级 高等级 为除以 rate
高等级 低等级 为乘以 rate最高等级货币元玺 rate 固定为 1
"""
if from_level == to_level:
return amount
category = self.env['game.rule.category'].sudo().search(
[('key', '=', category_key)], limit=1
)
if not category:
return amount
recs = self.search(
[('category_id', '=', category.id)],
order='level asc',
)
rate_map = {r.level: r.rate or 1 for r in recs}
if from_level < to_level:
result = amount
for lv in range(from_level, to_level):
result = result / rate_map.get(lv, 1)
return result
else:
result = amount
for lv in range(to_level, from_level):
result = result * rate_map.get(lv, 1)
return result

View File

@ -1,18 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class GameRuleCategory(models.Model):
_name = 'game.rule.category'
_description = '规则类别'
_order = 'sequence, id'
_rec_name = 'name'
_sql_constraints = [
('key_uniq', 'unique(key)', '规则类别标识必须唯一'),
]
key = fields.Char(string='标识', required=True, index=True)
name = fields.Char(string='名称', required=True)
sequence = fields.Integer(string='排序', default=10)
active = fields.Boolean(string='启用', default=True)

View File

@ -1,27 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import models, fields
class GameStoryVolume(models.Model):
_name = 'game.story.volume'
_description = '小说卷'
_order = 'sequence, id'
_rec_name = 'name'
name = fields.Char(string='卷名', required=True, index=True)
sequence = fields.Integer(string='序号', default=10, index=True)
intro = fields.Html(string='卷首语')
chapter_ids = fields.One2many('game.story.chapter', 'volume_id', string='章节')
class GameStoryChapter(models.Model):
_name = 'game.story.chapter'
_description = '小说章节'
_order = 'volume_id, sequence, id'
_rec_name = 'name'
volume_id = fields.Many2one('game.story.volume', string='所属卷',
required=True, ondelete='cascade', index=True)
name = fields.Char(string='章名', required=True, index=True)
sequence = fields.Integer(string='序号', default=10, index=True)
body_html = fields.Html(string='正文')

View File

@ -7,15 +7,11 @@ access_game_forum_post,game.forum.post public,model_game_forum_post,base.group_p
access_game_forum_post_user,game.forum.post user,model_game_forum_post,base.group_user,1,1,1,1 access_game_forum_post_user,game.forum.post user,model_game_forum_post,base.group_user,1,1,1,1
access_game_announcement,game.announcement public,model_game_announcement,base.group_public,1,0,0,0 access_game_announcement,game.announcement public,model_game_announcement,base.group_public,1,0,0,0
access_game_announcement_user,game.announcement user,model_game_announcement,base.group_user,1,1,1,1 access_game_announcement_user,game.announcement user,model_game_announcement,base.group_user,1,1,1,1
access_game_rule_category,game.rule.category public,model_game_rule_category,base.group_public,1,0,0,0
access_game_rule_category_user,game.rule.category user,model_game_rule_category,base.group_user,1,1,1,1
access_game_rule,game.rule public,model_game_rule,base.group_public,1,0,0,0 access_game_rule,game.rule public,model_game_rule,base.group_public,1,0,0,0
access_game_rule_user,game.rule user,model_game_rule,base.group_user,1,1,1,1 access_game_rule_user,game.rule user,model_game_rule,base.group_user,1,1,1,1
access_game_currency,game.currency public,model_game_currency,base.group_public,1,0,0,0
access_game_currency_user,game.currency user,model_game_currency,base.group_user,1,1,1,1
access_game_page,game.page public,model_game_page,base.group_public,1,0,0,0 access_game_page,game.page public,model_game_page,base.group_public,1,0,0,0
access_game_page_user,game.page user,model_game_page,base.group_user,1,1,1,1 access_game_page_user,game.page user,model_game_page,base.group_user,1,1,1,1
access_game_page_item,game.page.item public,model_game_page_item,base.group_public,1,0,0,0 access_game_page_item,game.page.item public,model_game_page_item,base.group_public,1,0,0,0
access_game_page_item_user,game.page.item user,model_game_page_item,base.group_user,1,1,1,1 access_game_page_item_user,game.page.item user,model_game_page_item,base.group_user,1,1,1,1
access_game_story_volume,game.story.volume public,model_game_story_volume,base.group_public,1,0,0,0
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

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_game_forum_post_user game.forum.post user model_game_forum_post base.group_user 1 1 1 1
8 access_game_announcement game.announcement public model_game_announcement base.group_public 1 0 0 0
9 access_game_announcement_user game.announcement user model_game_announcement base.group_user 1 1 1 1
access_game_rule_category game.rule.category public model_game_rule_category base.group_public 1 0 0 0
access_game_rule_category_user game.rule.category user model_game_rule_category base.group_user 1 1 1 1
10 access_game_rule game.rule public model_game_rule base.group_public 1 0 0 0
11 access_game_rule_user game.rule user model_game_rule base.group_user 1 1 1 1
12 access_game_currency game.currency public model_game_currency base.group_public 1 0 0 0
13 access_game_currency_user game.currency user model_game_currency base.group_user 1 1 1 1
14 access_game_page game.page public model_game_page base.group_public 1 0 0 0
15 access_game_page_user game.page user model_game_page base.group_user 1 1 1 1
16 access_game_page_item game.page.item public model_game_page_item base.group_public 1 0 0 0
17 access_game_page_item_user game.page.item user model_game_page_item base.group_user 1 1 1 1
access_game_story_volume game.story.volume public model_game_story_volume base.group_public 1 0 0 0
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

View File

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 公告 ============ -->
<record id="action_game_announcement" model="ir.actions.act_window">
<field name="name">公告</field>
<field name="res_model">game.announcement</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_game_announcement_list" model="ir.ui.view">
<field name="name">game.announcement.list</field>
<field name="model">game.announcement</field>
<field name="arch" type="xml">
<list><field name="name"/><field name="date"/><field name="priority"/><field name="active"/></list>
</field>
</record>
<record id="view_game_announcement_form" model="ir.ui.view">
<field name="name">game.announcement.form</field>
<field name="model">game.announcement</field>
<field name="arch" type="xml">
<form>
<group><field name="name"/><field name="date"/><field name="priority"/><field name="active"/></group>
<field name="body_html"/>
</form>
</field>
</record>
</odoo>

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 图鉴(按分类 + 子级分组) ============ -->
<record id="action_game_codex" model="ir.actions.act_window">
<field name="name">图鉴</field>
<field name="res_model">game.codex.entry</field>
<field name="view_mode">list,form</field>
<field name="context">{'group_by': 'cat'}</field>
</record>
<record id="view_game_codex_list" model="ir.ui.view">
<field name="name">game.codex.entry.list</field>
<field name="model">game.codex.entry</field>
<field name="arch" type="xml">
<list>
<field name="cat" column_invisible="1"/><field name="sub"/><field name="name"/><field name="tier"/><field name="image"/>
</list>
</field>
</record>
<record id="view_game_codex_form" model="ir.ui.view">
<field name="name">game.codex.entry.form</field>
<field name="model">game.codex.entry</field>
<field name="arch" type="xml">
<form>
<group>
<field name="cat"/><field name="sub"/><field name="tier"/>
</group>
<group>
<field name="name"/>
</group>
<field name="image" widget="image"/>
<field name="desc"/>
</form>
</field>
</record>
</odoo>

View File

@ -1,61 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 论坛 · 主题 ============ -->
<record id="action_game_forum_thread" model="ir.actions.act_window">
<field name="name">主题</field>
<field name="res_model">game.forum.thread</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_game_forum_thread_list" model="ir.ui.view">
<field name="name">game.forum.thread.list</field>
<field name="model">game.forum.thread</field>
<field name="arch" type="xml">
<list>
<field name="pinned"/><field name="name"/><field name="category"/><field name="author_name"/><field name="create_date"/>
</list>
</field>
</record>
<record id="view_game_forum_thread_form" model="ir.ui.view">
<field name="name">game.forum.thread.form</field>
<field name="model">game.forum.thread</field>
<field name="arch" type="xml">
<form>
<group>
<field name="name"/><field name="category"/><field name="author_name"/><field name="pinned"/>
</group>
<field name="body_html"/>
<field name="post_ids">
<tree><field name="author_name"/><field name="create_date"/></tree>
<form><field name="author_name"/><field name="body_html"/></form>
</field>
</form>
</field>
</record>
<!-- ============ 论坛 · 回复 ============ -->
<record id="action_game_forum_post" model="ir.actions.act_window">
<field name="name">回复</field>
<field name="res_model">game.forum.post</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_game_forum_post_list" model="ir.ui.view">
<field name="name">game.forum.post.list</field>
<field name="model">game.forum.post</field>
<field name="arch" type="xml">
<list><field name="thread_id"/><field name="author_name"/><field name="create_date"/></list>
</field>
</record>
<record id="view_game_forum_post_form" model="ir.ui.view">
<field name="name">game.forum.post.form</field>
<field name="model">game.forum.post</field>
<field name="arch" type="xml">
<form><field name="thread_id"/><field name="author_name"/><field name="body_html"/></form>
</field>
</record>
</odoo>

View File

@ -0,0 +1,206 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 顶层菜单(带品牌 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">
<field name="name">图鉴</field>
<field name="res_model">game.codex.entry</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_game_codex" name="图鉴" parent="menu_game_root" action="action_game_codex" sequence="10"/>
<record id="view_game_codex_list" model="ir.ui.view">
<field name="name">game.codex.entry.list</field>
<field name="model">game.codex.entry</field>
<field name="arch" type="xml">
<list>
<field name="cat"/><field name="sub"/><field name="name"/><field name="tier"/><field name="image"/>
</list>
</field>
</record>
<record id="view_game_codex_form" model="ir.ui.view">
<field name="name">game.codex.entry.form</field>
<field name="model">game.codex.entry</field>
<field name="arch" type="xml">
<form>
<group>
<field name="cat"/><field name="sub"/><field name="tier"/>
</group>
<group>
<field name="name"/>
</group>
<field name="image" widget="image"/>
<field name="desc"/>
</form>
</field>
</record>
<!-- ============ 规则(菜单仅保留「规则」二字,按类别分组) ============ -->
<record id="action_game_rule" model="ir.actions.act_window">
<field name="name">规则</field>
<field name="res_model">game.rule</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_game_rule" name="规则" parent="menu_game_root" action="action_game_rule" sequence="20"/>
<record id="view_game_rule_list" model="ir.ui.view">
<field name="name">game.rule.list</field>
<field name="model">game.rule</field>
<field name="arch" type="xml">
<list><field name="type"/><field name="level"/><field name="name"/></list>
</field>
</record>
<record id="view_game_rule_form" model="ir.ui.view">
<field name="name">game.rule.form</field>
<field name="model">game.rule</field>
<field name="arch" type="xml">
<form><group><field name="type"/><field name="level"/><field name="name"/></group><field name="desc"/></form>
</field>
</record>
<!-- ============ 货币等级 ============ -->
<record id="action_game_currency" model="ir.actions.act_window">
<field name="name">货币等级</field>
<field name="res_model">game.currency</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_game_currency" name="货币等级" parent="menu_game_root" action="action_game_currency" sequence="25"/>
<record id="view_game_currency_list" model="ir.ui.view">
<field name="name">game.currency.list</field>
<field name="model">game.currency</field>
<field name="arch" type="xml">
<list><field name="level"/><field name="name"/><field name="rate"/></list>
</field>
</record>
<record id="view_game_currency_form" model="ir.ui.view">
<field name="name">game.currency.form</field>
<field name="model">game.currency</field>
<field name="arch" type="xml">
<form><group><field name="level"/><field name="name"/><field name="rate"/></group><field name="desc"/></form>
</field>
</record>
<!-- ============ 页面板块 ============ -->
<record id="action_game_page" model="ir.actions.act_window">
<field name="name">页面板块</field>
<field name="res_model">game.page</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_game_page" name="页面板块" parent="menu_game_root" action="action_game_page" sequence="27"/>
<record id="view_game_page_list" model="ir.ui.view">
<field name="name">game.page.list</field>
<field name="model">game.page</field>
<field name="arch" type="xml">
<list>
<field name="sequence"/><field name="key"/><field name="title"/><field name="subtitle"/><field name="active"/>
</list>
</field>
</record>
<record id="view_game_page_form" model="ir.ui.view">
<field name="name">game.page.form</field>
<field name="model">game.page</field>
<field name="arch" type="xml">
<form>
<group>
<field name="key"/><field name="title"/><field name="subtitle"/><field name="sequence"/><field name="active"/>
</group>
<notebook>
<page string="正文" name="body">
<field name="body_html" widget="html" options="{'collaborative': true}"/>
</page>
<page string="条目" name="items">
<field name="item_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/><field name="icon"/><field name="title"/><field name="desc"/>
</list>
</field>
</page>
</notebook>
</form>
</field>
</record>
<!-- ============ 论坛(放后面) ============ -->
<menuitem id="menu_game_forum" name="论坛" parent="menu_game_root" sequence="30"/>
<record id="action_game_forum_thread" model="ir.actions.act_window">
<field name="name">主题</field>
<field name="res_model">game.forum.thread</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_game_forum_thread" name="主题" parent="menu_game_forum" action="action_game_forum_thread" sequence="31"/>
<record id="action_game_forum_post" model="ir.actions.act_window">
<field name="name">回复</field>
<field name="res_model">game.forum.post</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_game_forum_post" name="回复" parent="menu_game_forum" action="action_game_forum_post" sequence="32"/>
<record id="view_game_forum_thread_list" model="ir.ui.view">
<field name="name">game.forum.thread.list</field>
<field name="model">game.forum.thread</field>
<field name="arch" type="xml">
<list>
<field name="pinned"/><field name="name"/><field name="category"/><field name="author_name"/><field name="create_date"/>
</list>
</field>
</record>
<record id="view_game_forum_thread_form" model="ir.ui.view">
<field name="name">game.forum.thread.form</field>
<field name="model">game.forum.thread</field>
<field name="arch" type="xml">
<form>
<group>
<field name="name"/><field name="category"/><field name="author_name"/><field name="pinned"/>
</group>
<field name="body_html"/>
<field name="post_ids">
<tree><field name="author_name"/><field name="create_date"/></tree>
<form><field name="author_name"/><field name="body_html"/></form>
</field>
</form>
</field>
</record>
<record id="view_game_forum_post_list" model="ir.ui.view">
<field name="name">game.forum.post.list</field>
<field name="model">game.forum.post</field>
<field name="arch" type="xml">
<list><field name="thread_id"/><field name="author_name"/><field name="create_date"/></list>
</field>
</record>
<record id="view_game_forum_post_form" model="ir.ui.view">
<field name="name">game.forum.post.form</field>
<field name="model">game.forum.post</field>
<field name="arch" type="xml">
<form><field name="thread_id"/><field name="author_name"/><field name="body_html"/></form>
</field>
</record>
<!-- ============ 公告(放最后) ============ -->
<record id="action_game_announcement" model="ir.actions.act_window">
<field name="name">公告</field>
<field name="res_model">game.announcement</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_game_announcement" name="公告" parent="menu_game_root" action="action_game_announcement" sequence="40"/>
<record id="view_game_announcement_list" model="ir.ui.view">
<field name="name">game.announcement.list</field>
<field name="model">game.announcement</field>
<field name="arch" type="xml">
<list><field name="name"/><field name="date"/><field name="priority"/><field name="active"/></list>
</field>
</record>
<record id="view_game_announcement_form" model="ir.ui.view">
<field name="name">game.announcement.form</field>
<field name="model">game.announcement</field>
<field name="arch" type="xml">
<form>
<group><field name="name"/><field name="date"/><field name="priority"/><field name="active"/></group>
<field name="body_html"/>
</form>
</field>
</record>
</odoo>

View File

@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 顶层菜单(带品牌 logo ============ -->
<menuitem id="menu_game_root" name="宇森游戏" sequence="10"
web_icon="game_base,static/description/icon.png"/>
<!-- 图鉴 -->
<menuitem id="menu_game_codex" name="图鉴" parent="menu_game_root" action="action_game_codex" sequence="10"/>
<!-- 规则(容器:规则内容) -->
<menuitem id="menu_game_rule" name="规则" parent="menu_game_root" sequence="20"/>
<record model="ir.ui.menu" id="menu_game_rule">
<field name="action" eval="False"/>
</record>
<menuitem id="menu_game_rule_entry" name="规则" parent="menu_game_rule" action="action_game_rule" sequence="21"/>
<!-- 页面板块 -->
<menuitem id="menu_game_page" name="页面板块" parent="menu_game_root" action="action_game_page" sequence="27"/>
<!-- 论坛 -->
<menuitem id="menu_game_forum" name="论坛" parent="menu_game_root" sequence="30"/>
<menuitem id="menu_game_forum_thread" name="主题" parent="menu_game_forum" action="action_game_forum_thread" sequence="31"/>
<menuitem id="menu_game_forum_post" name="回复" parent="menu_game_forum" action="action_game_forum_post" sequence="32"/>
<!-- 公告 -->
<menuitem id="menu_game_announcement" name="公告" parent="menu_game_root" action="action_game_announcement" sequence="40"/>
<!-- 小说(剧情) -->
<menuitem id="menu_game_story" name="小说" parent="menu_game_root" sequence="35"/>
<menuitem id="menu_game_story_volume" name="卷" parent="menu_game_story" action="action_game_story_volume" sequence="36"/>
<menuitem id="menu_game_story_chapter" name="章节" parent="menu_game_story" action="action_game_story_chapter" sequence="37"/>
<!-- 配置 -->
<menuitem id="menu_game_config" name="配置" parent="menu_game_root" sequence="50"/>
<record model="ir.ui.menu" id="menu_game_config">
<field name="action" eval="False"/>
</record>
<menuitem id="menu_game_rule_category" name="规则类别" parent="menu_game_config" action="action_game_rule_category" sequence="51"/>
</odoo>

View File

@ -1,45 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 页面板块 ============ -->
<record id="action_game_page" model="ir.actions.act_window">
<field name="name">页面板块</field>
<field name="res_model">game.page</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_game_page_list" model="ir.ui.view">
<field name="name">game.page.list</field>
<field name="model">game.page</field>
<field name="arch" type="xml">
<list>
<field name="title"/>
</list>
</field>
</record>
<record id="view_game_page_form" model="ir.ui.view">
<field name="name">game.page.form</field>
<field name="model">game.page</field>
<field name="arch" type="xml">
<form>
<group>
<field name="key"/><field name="title"/><field name="subtitle"/><field name="sequence"/><field name="active"/>
</group>
<notebook>
<page string="正文" name="body">
<field name="body_html" widget="html" options="{'collaborative': true}"/>
</page>
<page string="条目" name="items">
<field name="item_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/><field name="icon"/><field name="title"/><field name="desc"/>
</list>
</field>
</page>
</notebook>
</form>
</field>
</record>
</odoo>

View File

@ -1,39 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 规则类别(配置菜单入口) ============ -->
<record id="action_game_rule_category" model="ir.actions.act_window">
<field name="name">规则类别</field>
<field name="res_model">game.rule.category</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_game_rule_category_list" model="ir.ui.view">
<field name="name">game.rule.category.list</field>
<field name="model">game.rule.category</field>
<field name="arch" type="xml">
<list editable="top">
<field name="sequence" widget="handle"/>
<field name="key"/>
<field name="name"/>
<field name="active" widget="boolean_toggle"/>
</list>
</field>
</record>
<record id="view_game_rule_category_form" model="ir.ui.view">
<field name="name">game.rule.category.form</field>
<field name="model">game.rule.category</field>
<field name="arch" type="xml">
<form>
<group>
<field name="key"/>
<field name="name"/>
<field name="sequence"/>
<field name="active" widget="boolean_toggle"/>
</group>
</form>
</field>
</record>
</odoo>

View File

@ -1,43 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 规则(按类别分组,列表隐藏类别列) ============ -->
<record id="action_game_rule" model="ir.actions.act_window">
<field name="name">规则</field>
<field name="res_model">game.rule</field>
<field name="view_mode">list,form</field>
<field name="context">{'group_by': 'category_id'}</field>
</record>
<record id="view_game_rule_list" model="ir.ui.view">
<field name="name">game.rule.list</field>
<field name="model">game.rule</field>
<field name="arch" type="xml">
<list>
<field name="category_id" column_invisible="1"/>
<field name="level"/>
<field name="name"/>
</list>
</field>
</record>
<record id="view_game_rule_form" model="ir.ui.view">
<field name="name">game.rule.form</field>
<field name="model">game.rule</field>
<field name="arch" type="xml">
<form>
<group>
<field name="category_id"/>
<field name="level"/>
<field name="name"/>
</group>
<group invisible="not category_id or category_id.key != 'currency'">
<field name="rate"/>
<field name="note"/>
</group>
<field name="desc"/>
</form>
</field>
</record>
</odoo>

View File

@ -1,61 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ============ 小说(剧情) · 卷 ============ -->
<record id="action_game_story_volume" model="ir.actions.act_window">
<field name="name"></field>
<field name="res_model">game.story.volume</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_game_story_volume_list" model="ir.ui.view">
<field name="name">game.story.volume.list</field>
<field name="model">game.story.volume</field>
<field name="arch" type="xml">
<list><field name="sequence"/><field name="name"/><field name="chapter_ids"/></list>
</field>
</record>
<record id="view_game_story_volume_form" model="ir.ui.view">
<field name="name">game.story.volume.form</field>
<field name="model">game.story.volume</field>
<field name="arch" type="xml">
<form>
<group><field name="name"/><field name="sequence"/></group>
<field name="intro"/>
<field name="chapter_ids">
<list editable="bottom">
<field name="sequence" widget="handle"/><field name="name"/><field name="body_html"/>
</list>
</field>
</form>
</field>
</record>
<!-- ============ 小说(剧情) · 章节 ============ -->
<record id="action_game_story_chapter" model="ir.actions.act_window">
<field name="name">章节</field>
<field name="res_model">game.story.chapter</field>
<field name="view_mode">list,form</field>
</record>
<record id="view_game_story_chapter_list" model="ir.ui.view">
<field name="name">game.story.chapter.list</field>
<field name="model">game.story.chapter</field>
<field name="arch" type="xml">
<list><field name="volume_id"/><field name="sequence"/><field name="name"/></list>
</field>
</record>
<record id="view_game_story_chapter_form" model="ir.ui.view">
<field name="name">game.story.chapter.form</field>
<field name="model">game.story.chapter</field>
<field name="arch" type="xml">
<form>
<group><field name="volume_id"/><field name="sequence"/><field name="name"/></group>
<field name="body_html"/>
</form>
</field>
</record>
</odoo>

View File

@ -15,6 +15,8 @@
<list> <list>
<field name="name"/> <field name="name"/>
<field name="user_id"/> <field name="user_id"/>
<field name="seed"/>
<field name="world_version"/>
<field name="last_active"/> <field name="last_active"/>
</list> </list>
</field> </field>
@ -29,6 +31,8 @@
<group> <group>
<field name="name"/> <field name="name"/>
<field name="user_id"/> <field name="user_id"/>
<field name="seed"/>
<field name="world_version"/>
<field name="settings" widget="json"/> <field name="settings" widget="json"/>
<field name="last_active"/> <field name="last_active"/>
</group> </group>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

View File

@ -60,12 +60,6 @@ const API = {
async announcements() { async announcements() {
return (await fetch(_url('/game/api/announcements'), { credentials: _cred })).json(); return (await fetch(_url('/game/api/announcements'), { credentials: _cred })).json();
}, },
async story() {
return (await fetch(_url('/game/api/story'), { credentials: _cred })).json();
},
async storyChapter(cid) {
return (await fetch(_url('/game/api/story/chapter/' + cid), { credentials: _cred })).json();
},
// ---------- 沙盘记忆yt_world 模块) ---------- // ---------- 沙盘记忆yt_world 模块) ----------
async world() { async world() {
return (await fetch('/yt_world/api/world?db=yt_game', { credentials: 'same-origin' })).json(); return (await fetch('/yt_world/api/world?db=yt_game', { credentials: 'same-origin' })).json();
@ -84,19 +78,4 @@ const API = {
body: JSON.stringify({ settings }), body: JSON.stringify({ settings }),
})).json(); })).json();
}, },
// ---------- 世界数据 / 包裹后端接口预留yt_world 暂未实现路由,前端 mock 兜底) ----------
async worldData() {
try {
const r = await fetch('/yt_world/api/data?db=yt_game', { credentials: 'same-origin' });
if (!r.ok) return null;
return await r.json();
} catch (e) { return null; }
},
async inventory() {
try {
const r = await fetch('/yt_world/api/inventory?db=yt_game', { credentials: 'same-origin' });
if (!r.ok) return null;
return await r.json();
} catch (e) { return null; }
},
}; };

View File

@ -32,6 +32,9 @@ async function loadPages(){
else if (p.key === 'letter') html += letterHTML(p); else if (p.key === 'letter') html += letterHTML(p);
}); });
if (bg) bg.innerHTML = html; // 特色 + 背景 + 致玩家 平铺进同一页,无子菜单切换 if (bg) bg.innerHTML = html; // 特色 + 背景 + 致玩家 平铺进同一页,无子菜单切换
const story = document.getElementById('story');
if (story) story.innerHTML = '<div class="sec-title">剧情<small>STORY</small></div><div style="text-align:center;padding:80px 20px;color:#556677;font-size:15px;"><div style="font-size:48px;margin-bottom:16px;">📖</div>剧情内容正在筹备中…<div style="font-size:13px;margin-top:8px;color:#445566;">敬请期待</div></div>';
} catch (e) { /* 失败时保留 HTML 中的静态兜底内容 */ } } catch (e) { /* 失败时保留 HTML 中的静态兜底内容 */ }
} }
@ -96,51 +99,9 @@ async function loadAnnounce(){
} catch (e) { /* 保留静态兜底内容 */ } } catch (e) { /* 保留静态兜底内容 */ }
} }
// ---------- 剧情(小说阅读器:左侧目录 + 右侧正文) ----------
async function loadStory(){
const tocList = document.getElementById('storyTocList');
const reader = document.getElementById('storyReader');
if (!tocList || !reader) return;
let data;
try { data = await API.story(); }
catch (e) { tocList.innerHTML = '<div class="toc-loading">目录加载失败</div>'; return; }
const volumes = (data && data.volumes) || [];
if (!volumes.length){ tocList.innerHTML = '<div class="toc-loading">暂无内容</div>'; return; }
const volNameByCh = {};
let firstCid = null;
const html = volumes.map(v => {
const chs = (v.chapters || []).map(c => {
volNameByCh[c.id] = v.name;
if (firstCid === null) firstCid = c.id;
return '<button class="toc-ch" data-cid="' + c.id + '">' + c.name + '</button>';
}).join('');
return '<div class="toc-vol"><div class="vname">' + v.name + '</div>' +
(v.intro ? '<div class="vintro">' + v.intro + '</div>' : '') + chs + '</div>';
}).join('');
tocList.innerHTML = html;
async function openChapter(cid){
tocList.querySelectorAll('.toc-ch').forEach(b => b.classList.toggle('active', String(b.dataset.cid) === String(cid)));
reader.innerHTML = '<div class="reader-loading">加载中…</div>';
try {
const d = await API.storyChapter(cid);
if (!d || d.error){ reader.innerHTML = '<div class="reader-loading">' + ((d && d.error) || '章节加载失败') + '</div>'; return; }
reader.innerHTML =
'<div class="reader-inner"><div class="r-vol">' + (volNameByCh[cid] || '') + '</div>' +
'<h1>' + d.name + '</h1><div class="body">' + (d.body_html || '<p>(暂无正文)</p>') + '</div></div>';
reader.scrollTop = 0;
} catch (e) { reader.innerHTML = '<div class="reader-loading">章节加载失败</div>'; }
}
tocList.querySelectorAll('.toc-ch').forEach(b => b.addEventListener('click', () => openChapter(b.dataset.cid)));
if (firstCid !== null) openChapter(firstCid);
}
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadPages(); loadPages();
loadRules(); loadRules();
loadForum(); loadForum();
loadAnnounce(); loadAnnounce();
loadStory();
}); });

View File

@ -36,7 +36,7 @@
.app{position:relative;z-index:2;height:100vh;display:flex;flex-direction:column;} .app{position:relative;z-index:2;height:100vh;display:flex;flex-direction:column;}
.topbar{height:64px;flex:0 0 auto;display:flex;align-items:center;gap:22px;padding:0 30px;border-bottom:1px solid var(--card-bd);background:rgba(8,12,26,.55);backdrop-filter:blur(10px);} .topbar{height:64px;flex:0 0 auto;display:flex;align-items:center;gap:22px;padding:0 30px;border-bottom:1px solid var(--card-bd);background:rgba(8,12,26,.55);backdrop-filter:blur(10px);}
.logo{display:flex;align-items:baseline;gap:9px;cursor:pointer;} .logo{display:flex;align-items:baseline;gap:9px;}
.logo .cn{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:28px;letter-spacing:4px;color:#fff;text-shadow:0 0 16px rgba(139,92,246,.55);} .logo .cn{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:28px;letter-spacing:4px;color:#fff;text-shadow:0 0 16px rgba(139,92,246,.55);}
.logo .en{font-family:"Press Start 2P",monospace;font-size:9px;color:var(--magenta);letter-spacing:1px;} .logo .en{font-family:"Press Start 2P",monospace;font-size:9px;color:var(--magenta);letter-spacing:1px;}
.nav{display:flex;gap:6px;margin-left:12px;} .nav{display:flex;gap:6px;margin-left:12px;}
@ -87,24 +87,7 @@
.card p{font-size:13px;color:var(--text-dim);line-height:1.6;} .card p{font-size:13px;color:var(--text-dim);line-height:1.6;}
/* ===== 背景 ===== */ /* ===== 背景 ===== */
#background{flex-direction:column;align-items:stretch;gap:0;overflow-y:auto;padding:48px 6% 90px;} #background{flex-direction:row;gap:38px;align-items:stretch;}
.bg-page{max-width:1060px;width:100%;margin:0 auto;padding:30px 0;border-top:1px solid var(--card-bd);}
.bg-page:first-child{border-top:none;padding-top:0;}
.bg-page-head{text-align:center;margin-bottom:28px;}
.bg-page-title{font-family:"Noto Serif SC",serif;font-weight:800;font-size:40px;letter-spacing:3px;line-height:1.2;margin:0 0 10px;
background:linear-gradient(120deg,#ffffff 0%,#f0d49a 48%,#c79bff 100%);
-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
.bg-page-sub{font-size:13px;letter-spacing:7px;color:var(--text-dim);text-transform:uppercase;}
.bg-page-body{font-size:17px;line-height:2.05;color:#e2e8f6;max-width:840px;margin:0 auto 34px;}
.bg-page-body p{margin:0 0 18px;}
.bg-page-items{display:grid;grid-template-columns:repeat(auto-fill,minmax(262px,1fr));gap:18px;}
.bg-card{display:flex;gap:15px;align-items:flex-start;background:linear-gradient(135deg,rgba(139,92,246,.10),rgba(76,201,240,.05));
border:1px solid var(--card-bd);border-radius:16px;padding:18px 20px;transition:.26s;}
.bg-card:hover{transform:translateY(-4px);border-color:var(--violet);box-shadow:var(--glow);background:rgba(139,92,246,.16);}
.bg-card-ic{font-size:27px;line-height:1.15;filter:drop-shadow(0 0 9px rgba(139,92,246,.55));flex:0 0 auto;}
.bg-card-title{font-size:17px;font-weight:700;color:#fff;margin-bottom:6px;}
.bg-card-desc{font-size:14.5px;line-height:1.66;color:var(--text-dim);}
.bg-empty{text-align:center;color:var(--text-dim);padding:70px 0;font-size:16px;}
.layers{flex:0 0 270px;display:flex;flex-direction:column;gap:8px;justify-content:center;} .layers{flex:0 0 270px;display:flex;flex-direction:column;gap:8px;justify-content:center;}
.layer{border-radius:12px;padding:11px 15px;border:1px solid var(--card-bd);position:relative;} .layer{border-radius:12px;padding:11px 15px;border:1px solid var(--card-bd);position:relative;}
.layer .lt{font-size:15px;color:#fff;font-weight:500;letter-spacing:1px;} .layer .lt{font-size:15px;color:#fff;font-weight:500;letter-spacing:1px;}
@ -172,7 +155,7 @@
.threads{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding-right:6px;} .threads{flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding-right:6px;}
.th{display:flex;align-items:center;gap:14px;background:var(--card);border:1px solid var(--card-bd);border-radius:11px;padding:13px 16px;transition:.2s;cursor:pointer;} .th{display:flex;align-items:center;gap:14px;background:var(--card);border:1px solid var(--card-bd);border-radius:11px;padding:13px 16px;transition:.2s;cursor:pointer;}
.th:hover{border-color:var(--violet);background:rgba(139,92,246,.10);} .th:hover{border-color:var(--violet);background:rgba(139,92,246,.10);}
.th .pin{font-size:11px;font-weight:700;letter-spacing:1px;color:var(--gold);background:rgba(231,184,92,.14);border:1px solid rgba(231,184,92,.42);border-radius:5px;padding:1px 7px;} .th .pin{font-size:10px;font-family:"Press Start 2P",monospace;color:var(--gold);}
.th .tt{flex:1;font-size:15px;color:#fff;} .th .tt{flex:1;font-size:15px;color:#fff;}
.th .meta{font-size:12px;color:var(--text-dim);white-space:nowrap;} .th .meta{font-size:12px;color:var(--text-dim);white-space:nowrap;}
.th .rp{color:var(--cyan);font-weight:500;} .th .rp{color:var(--cyan);font-weight:500;}
@ -203,97 +186,10 @@
#letter .letter-wrap .lt-body .hl{color:var(--gold);font-weight:500;} #letter .letter-wrap .lt-body .hl{color:var(--gold);font-weight:500;}
#letter .letter-wrap .lt-sign{text-align:right;margin-top:28px;font-size:14px;color:var(--magenta);font-style:italic;} #letter .letter-wrap .lt-sign{text-align:right;margin-top:28px;font-size:14px;color:var(--magenta);font-style:italic;}
/* ===== 论坛(竖排:顶部分类条 + 下方纵向帖子流) ===== */
#forum{flex-direction:column;gap:16px;}
.f-cats{flex:0 0 auto;flex-direction:row;flex-wrap:wrap;gap:8px;}
.f-cats .ch{flex:0 0 auto;}
.f-main{flex:1;min-height:0;}
.threads{max-width:900px;width:100%;}
/* ===== 公告(居中竖排) ===== */
#announce{align-items:center;}
#announce .notes{max-width:880px;width:100%;margin:0 auto;}
/* ===== 剧情(小说阅读器:左目录 + 右正文,竖排阅读感) ===== */
#story{padding:0;}
.story-wrap{display:flex;flex-direction:row;width:100%;height:100%;min-height:0;}
.story-toc{flex:0 0 286px;display:flex;flex-direction:column;border-right:1px solid var(--card-bd);background:rgba(8,12,26,.35);min-height:0;}
.toc-head{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:22px;letter-spacing:3px;color:#fff;padding:24px 22px 10px;text-shadow:0 0 16px rgba(139,92,246,.5);}
.toc-head small{display:block;font-family:"Press Start 2P",monospace;font-size:8px;color:var(--magenta);letter-spacing:1px;margin-top:6px;}
.toc-list{flex:1;overflow-y:auto;padding:0 12px 24px;}
.toc-loading{padding:30px 16px;color:var(--text-dim);font-size:14px;}
.toc-vol{margin-top:10px;}
.toc-vol .vname{font-size:13px;color:var(--gold);letter-spacing:1px;padding:8px 10px 4px;font-weight:500;}
.toc-vol .vintro{font-size:12px;color:var(--text-dim);line-height:1.6;padding:0 10px 8px;}
.toc-ch{display:block;width:100%;text-align:left;font-family:inherit;font-size:13.5px;color:var(--text-dim);background:transparent;border:none;border-left:2px solid transparent;padding:9px 12px;cursor:pointer;transition:.18s;border-radius:0 8px 8px 0;line-height:1.4;}
.toc-ch:hover{color:#fff;background:rgba(139,92,246,.12);}
.toc-ch.active{color:#fff;background:rgba(139,92,246,.20);border-left-color:var(--magenta);}
.story-reader{flex:1;overflow-y:auto;min-width:0;background:linear-gradient(180deg, rgba(139,92,246,.04), transparent 240px);}
.reader-loading{padding:80px 20px;text-align:center;color:var(--text-dim);font-size:15px;}
.reader-inner{max-width:760px;margin:0 auto;padding:44px 48px 90px;}
.reader-inner .r-vol{font-size:12px;color:var(--cyan);letter-spacing:2px;margin-bottom:10px;}
.reader-inner h1{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:30px;letter-spacing:2px;color:#fff;text-shadow:0 0 16px rgba(139,92,246,.5);margin-bottom:18px;line-height:1.3;}
.reader-inner .body{font-size:15.5px;color:var(--text);line-height:2.15;}
.reader-inner .body p{margin-bottom:18px;text-indent:2em;}
.reader-inner .body p:first-child{text-indent:0;}
.reader-inner .body h2,.reader-inner .body h3{color:#fff;margin:22px 0 10px;font-size:18px;}
/* ===== 世界数据报表 ===== */
#worlddata{overflow-y:auto;padding:46px 6% 90px;}
#worldDataContent{max-width:1080px;width:100%;margin:0 auto;}
.wd-tag{display:inline-block;font-size:12px;letter-spacing:2px;color:var(--gold);background:rgba(231,184,92,.12);border:1px solid rgba(231,184,92,.38);border-radius:6px;padding:3px 11px;margin-bottom:22px;}
.wd-h{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:22px;letter-spacing:3px;color:#fff;margin:38px 0 18px;text-shadow:0 0 14px rgba(139,92,246,.45);display:flex;align-items:center;gap:10px;}
.wd-h::before{content:"";width:4px;height:22px;border-radius:3px;background:linear-gradient(180deg,var(--violet),var(--cyan));box-shadow:0 0 10px rgba(139,92,246,.6);}
.wd-h small{font-family:"Press Start 2P",monospace;font-size:8px;color:var(--magenta);letter-spacing:1px;}
.wd-kpi{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:16px;}
.wd-kpi-card{position:relative;overflow:hidden;background:linear-gradient(135deg,rgba(139,92,246,.12),rgba(76,201,240,.05));border:1px solid var(--card-bd);border-radius:16px;padding:18px 20px;transition:.26s;}
.wd-kpi-card:hover{transform:translateY(-4px);border-color:var(--violet);box-shadow:var(--glow);}
.wd-kpi-card::after{content:"";position:absolute;right:-26px;top:-26px;width:88px;height:88px;border-radius:50%;background:radial-gradient(circle,rgba(199,125,255,.22),transparent 70%);}
.wd-kpi-ic{font-size:22px;filter:drop-shadow(0 0 7px rgba(139,92,246,.55));}
.wd-kpi-num{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:34px;line-height:1.1;color:#fff;margin:8px 0 2px;}
.wd-kpi-num .u{font-size:14px;color:var(--text-dim);margin-left:4px;letter-spacing:1px;}
.wd-kpi-label{font-size:13px;color:var(--text-dim);letter-spacing:1px;}
.wd-kpi-delta{position:absolute;right:14px;top:16px;font-size:11px;font-weight:700;padding:1px 7px;border-radius:5px;}
.wd-up{color:#7CFC9B;background:rgba(124,252,155,.12);}
.wd-down{color:#ff8585;background:rgba(255,133,133,.12);}
.wd-bars{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px 26px;}
.wd-bar-row{margin-bottom:4px;}
.wd-bar-top{display:flex;justify-content:space-between;font-size:13.5px;margin-bottom:6px;}
.wd-bar-top .k{color:var(--text);}
.wd-bar-top .v{color:var(--gold);font-weight:700;}
.wd-track{height:9px;border-radius:6px;background:rgba(255,255,255,.07);overflow:hidden;}
.wd-fill{height:100%;border-radius:6px;background:linear-gradient(90deg,var(--cyan),var(--violet));box-shadow:0 0 9px rgba(139,92,246,.55);}
.wd-table{width:100%;border-collapse:separate;border-spacing:0;font-size:14px;background:rgba(255,255,255,.02);border:1px solid var(--card-bd);border-radius:14px;overflow:hidden;}
.wd-table th{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:14px;letter-spacing:2px;color:#fff;background:linear-gradient(135deg,rgba(139,92,246,.22),rgba(76,201,240,.12));text-align:left;padding:13px 16px;border-bottom:1px solid var(--card-bd);}
.wd-table td{padding:12px 16px;border-bottom:1px solid rgba(255,255,255,.06);color:var(--text-dim);line-height:1.5;}
.wd-table tr:last-child td{border-bottom:none;}
.wd-table tr:hover td{background:rgba(139,92,246,.08);color:#eaf0ff;}
.wd-table .lead{color:#fff;font-weight:600;}
.wd-table .pill{display:inline-block;font-size:12px;padding:2px 9px;border-radius:20px;background:rgba(76,201,240,.14);color:var(--cyan);border:1px solid rgba(76,201,240,.34);}
.wd-rank{display:flex;flex-direction:column;gap:12px;}
.wd-rank-row{display:flex;align-items:center;gap:14px;background:linear-gradient(135deg,rgba(139,92,246,.08),rgba(255,255,255,.02));border:1px solid var(--card-bd);border-radius:13px;padding:12px 16px;transition:.22s;}
.wd-rank-row:hover{border-color:var(--violet);transform:translateX(4px);}
.wd-rank-no{font-family:"ZCOOL QingKe HuangYou",sans-serif;font-size:22px;width:34px;text-align:center;color:var(--gold);flex:0 0 auto;}
.wd-rank-row:nth-child(1) .wd-rank-no{color:#ffd56b;}
.wd-rank-row:nth-child(2) .wd-rank-no{color:#cfd8e8;}
.wd-rank-row:nth-child(3) .wd-rank-no{color:#e0a86b;}
.wd-rank-main{flex:1;min-width:0;}
.wd-rank-name{font-size:15px;color:#fff;margin-bottom:5px;display:flex;justify-content:space-between;}
.wd-rank-name .s{color:var(--gold);font-weight:700;font-size:13px;}
.wd-rank-track{height:8px;border-radius:5px;background:rgba(255,255,255,.07);overflow:hidden;}
.wd-rank-fill{height:100%;border-radius:5px;background:linear-gradient(90deg,var(--magenta),var(--violet));box-shadow:0 0 8px rgba(199,125,255,.5);}
@media (max-width:900px){ @media (max-width:900px){
.grid{grid-template-columns:repeat(2,1fr);} .grid{grid-template-columns:repeat(2,1fr);}
#background,#forum{flex-direction:column;overflow-y:auto;} #background,#forum{flex-direction:column;overflow-y:auto;}
.layers,.f-cats{flex:none;} .layers,.f-cats{flex:none;}
#story .story-wrap{flex-direction:column;overflow-y:auto;}
.story-toc{flex:none;border-right:none;border-bottom:1px solid var(--card-bd);}
.toc-list{max-height:220px;}
.hero .copy h1{font-size:42px;} .hero .copy h1{font-size:42px;}
.nav button{padding:8px 10px;font-size:13px;} .nav button{padding:8px 10px;font-size:13px;}
.nav{overflow-x:auto;scrollbar-width:none;} .nav{overflow-x:auto;scrollbar-width:none;}
@ -304,12 +200,13 @@
<body> <body>
<div class="app"> <div class="app">
<header class="topbar"> <header class="topbar">
<div class="logo" id="logoHome"><span class="cn">宇森</span></div> <div class="logo"><span class="cn">宇森</span></div>
<nav class="nav"> <nav class="nav">
<button data-tab="intro" class="active">介绍</button>
<button data-tab="background">背景</button> <button data-tab="background">背景</button>
<button data-tab="rules">规则</button> <button data-tab="rules">规则</button>
<button data-tab="codex">图鉴</button> <button data-tab="codex">图鉴</button>
<button data-tab="forum" class="auth-only">论坛</button> <button data-tab="forum">论坛</button>
<button data-tab="announce">公告</button> <button data-tab="announce">公告</button>
<button data-tab="story">剧情</button> <button data-tab="story">剧情</button>
<button data-tab="worlddata" class="auth-only">世界数据</button> <button data-tab="worlddata" class="auth-only">世界数据</button>
@ -442,7 +339,7 @@
<div class="row"><button id="pfCancel">取消</button><button class="ok" id="pfOk">发布</button></div> <div class="row"><button id="pfCancel">取消</button><button class="ok" id="pfOk">发布</button></div>
</div> </div>
<div class="threads" id="threads"> <div class="threads" id="threads">
<div class="th" data-fcat="discuss"><span class="pin">置顶</span><span class="tt">【公告】宇森官网第一阶段上线,欢迎园丁们入驻</span><span class="meta"><span class="rp">128</span> 回复 · 官方</span></div> <div class="th" data-fcat="discuss"><span class="pin">PIN</span><span class="tt">【公告】宇森官网第一阶段上线,欢迎园丁们入驻</span><span class="meta"><span class="rp">128</span> 回复 · 官方</span></div>
<div class="th" data-fcat="guide"><span class="tt">新手向:园丁视角的三种开局思路</span><span class="meta"><span class="rp">42</span> 回复 · 行者</span></div> <div class="th" data-fcat="guide"><span class="tt">新手向:园丁视角的三种开局思路</span><span class="meta"><span class="rp">42</span> 回复 · 行者</span></div>
<div class="th" data-fcat="discuss"><span class="tt">你们更喜欢哪种族?来投个票</span><span class="meta"><span class="rp">76</span> 回复 · 雾岛</span></div> <div class="th" data-fcat="discuss"><span class="tt">你们更喜欢哪种族?来投个票</span><span class="meta"><span class="rp">76</span> 回复 · 雾岛</span></div>
<div class="th" data-fcat="fan"><span class="tt">手绘了我的主世界浮空岛,求轻喷</span><span class="meta"><span class="rp">31</span> 回复 · 青柠</span></div> <div class="th" data-fcat="fan"><span class="tt">手绘了我的主世界浮空岛,求轻喷</span><span class="meta"><span class="rp">31</span> 回复 · 青柠</span></div>
@ -464,23 +361,16 @@
</div> </div>
</section> </section>
<!-- ===== 剧情(小说阅读器:左目录 + 右正文) ===== --> <!-- ===== 剧情 ===== -->
<section class="panel" id="story"> <section class="panel" id="story"></section>
<div class="story-wrap">
<aside class="story-toc">
<div class="toc-head">目录<small>CONTENTS</small></div>
<div class="toc-list" id="storyTocList"><div class="toc-loading">加载中…</div></div>
</aside>
<article class="story-reader" id="storyReader">
<div class="reader-loading">从左侧目录选择章节开始阅读</div>
</article>
</div>
</section>
<!-- ===== 世界数据(登录后可见) ===== --> <!-- ===== 世界数据(登录后可见) ===== -->
<section class="panel" id="worlddata"> <section class="panel" id="worlddata">
<div class="sec-title">世界数据<small>WORLD DATA</small></div> <div class="sec-title">世界数据<small>WORLD DATA</small></div>
<div id="worldDataContent"></div> <div id="worldDataContent" style="text-align:center;padding:60px 20px;color:#556677;font-size:15px;">
<div style="font-size:40px;margin-bottom:14px;">🌍</div>
加载中…
</div>
</section> </section>
</main> </main>
</div> </div>
@ -496,13 +386,6 @@
document.getElementById(b.dataset.tab).classList.add('active'); document.getElementById(b.dataset.tab).classList.add('active');
})); }));
// logo 点击回主页intro 面板)
document.getElementById('logoHome').addEventListener('click',()=>{
navBtns.forEach(x=>x.classList.remove('active'));
panels.forEach(p=>p.classList.remove('active'));
document.getElementById('intro').classList.add('active');
});
// ============ 程序化矢量缩略图(零 AI 生图,不消耗积分) ============ // ============ 程序化矢量缩略图(零 AI 生图,不消耗积分) ============
function hashStr(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return h>>>0;} function hashStr(s){let h=2166136261;for(let i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,16777619);}return h>>>0;}
function rng(seed){let s=seed>>>0;return function(){s=(Math.imul(s,1664525)+1013904223)>>>0;return s/4294967296;};} function rng(seed){let s=seed>>>0;return function(){s=(Math.imul(s,1664525)+1013904223)>>>0;return s/4294967296;};}
@ -737,128 +620,6 @@
} }
refreshUserBox(); refreshUserBox();
// ============ 背景页:从 odoo 拉取页面板块渲染 ============
async function renderBackground(){
const box = document.getElementById('background');
if(!box) return;
try{
const r = await fetch('/game/api/pages?db=game', {credentials:'same-origin'});
const d = await r.json();
const pages = (d && d.items) || [];
if(!pages.length){ box.innerHTML = '<div class="bg-empty">板块内容即将上线…</div>'; return; }
box.innerHTML = pages.map(p=>{
const cards = (p.items||[]).map(it=>`
<div class="bg-card">
<div class="bg-card-ic">${it.icon||'✦'}</div>
<div class="bg-card-body">
<div class="bg-card-title">${it.title||''}</div>
<div class="bg-card-desc">${it.desc||''}</div>
</div>
</div>`).join('');
return `
<section class="bg-page">
<div class="bg-page-head">
<h2 class="bg-page-title">${p.title||''}</h2>
${p.subtitle?`<div class="bg-page-sub">${p.subtitle}</div>`:''}
</div>
${p.body_html?`<div class="bg-page-body">${p.body_html}</div>`:''}
${cards?`<div class="bg-page-items">${cards}</div>`:''}
</section>`;
}).join('');
}catch(e){
box.innerHTML = '<div class="bg-empty">板块加载失败,请刷新重试。</div>';
}
}
renderBackground();
// ============ 世界数据:大报表(演示数据,后端接口待接入) ============
async function renderWorldData(){
const box = document.getElementById('worldDataContent');
if(!box) return;
// TODO: 后端接入后改为 await API.worldData() 取真实数据
const data = worldDataMock();
const kpis = data.kpi.map(k=>`
<div class="wd-kpi-card">
<span class="wd-kpi-delta ${k.delta>=0?'wd-up':'wd-down'}">${k.delta>=0?'▲':'▼'} ${Math.abs(k.delta)}%</span>
<div class="wd-kpi-ic">${k.icon}</div>
<div class="wd-kpi-num">${k.num}<span class="u">${k.unit||''}</span></div>
<div class="wd-kpi-label">${k.label}</div>
</div>`).join('');
const bars = data.bars.map(b=>`
<div class="wd-bar-row">
<div class="wd-bar-top"><span class="k">${b.k}</span><span class="v">${b.v}%</span></div>
<div class="wd-track"><div class="wd-fill" style="width:${b.v}%"></div></div>
</div>`).join('');
const rows = data.layers.map(l=>`
<tr>
<td class="lead">${l.lv}</td>
<td>${l.band}</td>
<td>${l.eco}</td>
<td>${l.pop}</td>
<td><span class="pill">${l.terr}</span></td>
</tr>`).join('');
const ranks = data.factions.map((f,i)=>`
<div class="wd-rank-row">
<div class="wd-rank-no">${i+1}</div>
<div class="wd-rank-main">
<div class="wd-rank-name"><span>${f.name}</span><span class="s">${f.score} 影响力</span></div>
<div class="wd-rank-track"><div class="wd-rank-fill" style="width:${f.pct}%"></div></div>
</div>
</div>`).join('');
box.innerHTML = `
<span class="wd-tag">演示数据 · 后端接口待接入</span>
<div class="wd-kpi">${kpis}</div>
<div class="wd-h">核心指标进度 <small>CORE METRICS</small></div>
<div class="wd-bars">${bars}</div>
<div class="wd-h">世界五层结构 <small>WORLD LAYERS</small></div>
<table class="wd-table">
<thead><tr><th>层级</th><th>高度带</th><th>地表生态</th><th>人口占比</th><th>代表地形</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<div class="wd-h">势力影响力排行 <small>FACTION RANKING</small></div>
<div class="wd-rank">${ranks}</div>
`;
}
function worldDataMock(){
return {
kpi:[
{icon:'👥',num:'12,840',unit:'人',label:'世界总人口',delta:3},
{icon:'🌍',num:'Lv.7',unit:'',label:'当前世界等级',delta:1},
{icon:'🏛️',num:'38',unit:'个',label:'活跃势力',delta:5},
{icon:'⛪',num:'9.6M',unit:'',label:'信仰值总量',delta:8},
{icon:'⚔️',num:'214',unit:'',label:'在线角色',delta:-2},
{icon:'💎',num:'1.3M',unit:'',label:'流通晶币',delta:4}
],
bars:[
{k:'世界安定度',v:78},
{k:'生态健康度',v:64},
{k:'信仰传播度',v:88},
{k:'文明探索度',v:52},
{k:'势力平衡度',v:71},
{k:'资源充裕度',v:60}
],
layers:[
{lv:'第一层 · 地表',band:'080m',eco:'平原 / 森林 / 河流',pop:'58%',terr:'草原城邦'},
{lv:'第二层 · 丘陵',band:'80260m',eco:'梯田 / 矿脉 / 林地',pop:'22%',terr:'山道驿站'},
{lv:'第三层 · 高地',band:'260600m',eco:'苔原 / 雪线 / 圣峰',pop:'12%',terr:'云端神殿'},
{lv:'第四层 · 地下',band:'-30-200m',eco:'溶洞 / 晶矿 / 暗河',pop:'6%',terr:'地心工坊'},
{lv:'第五层 · 秘境',band:'位面裂隙',eco:'虚空 / 混沌 / 星界',pop:'2%',terr:'裂隙哨站'}
],
factions:[
{name:'曦光同盟',score:9420,pct:100},
{name:'玄铁工盟',score:8730,pct:93},
{name:'碧波联邦',score:7610,pct:81},
{name:'夜枭议会',score:6880,pct:73},
{name:'荒原游商',score:5240,pct:56},
{name:'星语祭司团',score:4310,pct:46}
]
};
}
renderWorldData();
// ============ 进入游戏:已登录→世界页;未登录→登录页 ============ // ============ 进入游戏:已登录→世界页;未登录→登录页 ============
const enterBtn = document.getElementById('enterGame'); const enterBtn = document.getElementById('enterGame');
if(enterBtn) enterBtn.addEventListener('click', async ()=>{ if(enterBtn) enterBtn.addEventListener('click', async ()=>{

View File

@ -1,144 +0,0 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>公告 · 新版预览</title>
<!-- 直接复用真实 auth.css新公告样式就在里面 -->
<link rel="stylesheet" href="auth.css">
<style>
/* 复用 intro.html 的页面壳样式(只这一页用,不污染生产代码) */
* { margin:0; padding:0; box-sizing:border-box; }
html { scroll-behavior:smooth; }
body {
font-family:'Microsoft YaHei','PingFang SC',sans-serif;
background:#06090f;
color:#c8d6e5;
line-height:1.7;
min-height:100vh;
}
body::before {
content:"";
position:fixed; inset:0; z-index:0; pointer-events:none;
background:
radial-gradient(800px circle at 20% 10%, rgba(74,175,255,.08), transparent 50%),
radial-gradient(600px circle at 80% 30%, rgba(167,139,250,.06), transparent 50%);
}
.page {
position:relative; z-index:1;
padding:48px 36px 60px;
max-width:980px; margin:0 auto;
}
h1 {
font-size:28px; font-weight:bold; margin-bottom:6px;
background:linear-gradient(135deg,#4af,#a8e6cf);
-webkit-background-clip:text; -webkit-text-fill-color:transparent;
}
.page-sub { color:#556677; font-size:14px; margin-bottom:32px; }
.preview-bar {
position:fixed; bottom:14px; left:14px; z-index:3000;
background:rgba(15,23,42,.85); border:1px solid rgba(167,139,250,.3);
color:#a78bfa; font-size:12px; padding:8px 14px; border-radius:8px;
backdrop-filter:blur(8px); letter-spacing:1px;
}
</style>
</head>
<body>
<div class="page">
<!-- ===== 公告面板 ===== -->
<div class="tab-panel active" id="panel-announce">
<h1>公 告</h1>
<p class="page-sub">官方通知 · ANNOUNCEMENTS</p>
<div id="announce" class="announce-list" aria-live="polite"></div>
</div>
</div>
<div class="preview-bar">⚡ 新版公告预览 · 点击卡片可弹窗</div>
<!-- Mock API.announcements 返回的数据,与截图 5 条对应 -->
<script>
window.API = {
announcements: async () => ([
{
id: 1,
name: '宇森官网第一阶段上线:介绍 / 特色 / 背景 / 规则 / 图鉴 / 论坛 / 公告 / 致玩家 八块,单页切换呈现。介绍页即概念宣传片。',
date: '2026-07-09',
priority: 'normal',
priority_label: '普通',
body_html: '<p>官网第一阶段上线,全站共 8 个板块,全部通过顶部 Tab 单页切换呈现。</p>' +
'<p>介绍页即概念宣传片,从世界五层结构、玩家园丁视角、活的文明系统三方面切入,让你 30 秒看懂宇森在做什么。</p>' +
'<p>其他板块将在后续逐步充实内容,欢迎常回来看看。</p>'
},
{
id: 2,
name: '图鉴系统升级:种族 204 条、系别 / 性格 / 天赋 / 神器 / 传奇事件全面扩量,装备槽位对齐 头 / 手 / 胸 / 裤 / 鞋 / 戒指(双) / 项链 / 宝物;全部缩略图改为程序化矢量插画,零 AI 生图。',
date: '2026-07-09',
priority: 'important',
priority_label: '重要',
body_html: '<p>本次图鉴系统全面升级,主要变化:</p>' +
'<ul>' +
'<li><strong>种族条目扩至 204 条</strong>,覆盖主流奇幻 + 自创世界观。</li>' +
'<li>系别 / 性格 / 天赋 / 神器 / 传奇事件 五类同步扩量。</li>' +
'<li>装备槽位与游戏内对齐:头 / 手 / 胸 / 裤 / 鞋 / 戒指(双) / 项链 / 宝物。</li>' +
'<li>所有缩略图改为<strong>程序化矢量插画</strong>,彻底告别 AI 生图,风格统一、可缩放、无水印。</li>' +
'</ul>' +
'<p>欢迎在「图鉴」页查看新版视觉。</p>'
},
{
id: 3,
name: '概念宣传片《垂直生长》完成,呈现五层世界自上而下生长的镜头语言(天空层改为云朵地基块,星空层改为陨石科技带)。',
date: '2026-07-08',
priority: 'normal',
priority_label: '普通',
body_html: '<p>《垂直生长》是一支 90 秒的概念宣传片,镜头从星空层一直钻到地心熔岩,逐层展现五层世界如何"自上而下"生长。</p>' +
'<p>制作中我们对两层做了重要调整:</p>' +
'<ul>' +
'<li><strong>天空层</strong>:从原来的气流层改为云朵地基块,视觉上更"可踩"。</li>' +
'<li><strong>星空层</strong>:从星座点缀改为陨石科技带,强调"远古文明残骸"的世界感。</li>' +
'</ul>' +
'<p>后续会放出完整版本到官网与 B 站。</p>'
},
{
id: 4,
name: '园丁视角核心玩法原型启动,首版六边形地图与活 NPC 验证中。',
date: '2026-07-01',
priority: 'normal',
priority_label: '普通',
body_html: '<p>核心玩法原型正式立项,目标验证两件事:</p>' +
'<ol>' +
'<li><strong>六边形地图</strong>:从正方形切到正六边形密铺,缩放 / 平移 / 邻居查询是否顺畅。</li>' +
'<li><strong>活 NPC</strong>:单个 NPC 是否有"性格 + 记忆 + 自主决策"三个属性,能否在简单场景里产生涌现行为。</li>' +
'</ol>' +
'<p>首批反馈预计 7 月中下旬出,会同步到公告和论坛。</p>'
},
{
id: 5,
name: '图鉴系统规划确定:种族 / 建筑 / 生物 / 神器 四大类目,美术持续补充中。',
date: '2026-06-20',
priority: 'normal',
priority_label: '普通',
body_html: '<p>图鉴系统四大类目确定:</p>' +
'<ul>' +
'<li><strong>种族</strong>:所有可玩 / NPC / 敌对种族</li>' +
'<li><strong>建筑</strong>:所有可建造 / 不可建造但可见的结构</li>' +
'<li><strong>生物</strong>:野外生态群与城镇人口</li>' +
'<li><strong>神器</strong>:剧情道具 + 玩家可制造的高阶装备</li>' +
'</ul>' +
'<p>美术采用程序化矢量插画路线,目标是"无 AI 生图也能保持高质量"。</p>'
}
])
};
</script>
<!-- 真实 site-app.js会调用 loadAnnounce() -->
<script src="assets/site-app.js"></script>
<!-- 预览页只关心公告,所以手动触发一次(生产页由 DOMContentLoaded 自动触发) -->
<script>
document.addEventListener('DOMContentLoaded', () => {
if (typeof loadAnnounce === 'function') loadAnnounce();
});
</script>
</body>
</html>

View File

@ -49,111 +49,17 @@ async function loadForum(){
} catch (e) { /* 保留静态兜底内容 */ } } catch (e) { /* 保留静态兜底内容 */ }
} }
// ---------- 公告(卡片 + 弹窗) ---------- // ---------- 公告 ----------
function annEscapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function annFormatDate(d) {
// "2026-07-09" -> "2026.07.09"
return d ? String(d).replace(/-/g, '.') : '';
}
function annStripHtml(html) {
const tmp = document.createElement('div');
tmp.innerHTML = html || '';
return (tmp.textContent || '').replace(/\s+/g, ' ').trim();
}
async function loadAnnounce(){ async function loadAnnounce(){
const root = document.getElementById('announce'); const el = document.querySelector('#announce .notes');
if (!root) return; if (!el) return;
root.innerHTML = '<div class="loading">加载中…</div>';
let list = [];
try { try {
list = await API.announcements(); const list = await API.announcements();
} catch (e) { /* 落到空态 */ } if (!list || !list.length){ el.innerHTML = '<div class="note"><div class="body">暂无公告</div></div>'; return; }
if (!list || !list.length) { el.innerHTML = list.map(n =>
root.innerHTML = '<div class="announce-empty">暂无公告</div>'; `<div class="note"><div class="date">${n.date}</div><div class="body">${n.body_html || ''}</div></div>`
return; ).join('');
} } catch (e) { /* 保留静态兜底内容 */ }
root.innerHTML = list.map((n, i) => {
const pri = n.priority || 'normal';
const preview = annStripHtml(n.body_html).slice(0, 160);
return `
<div class="announce-card priority-${annEscapeHtml(pri)}"
data-idx="${i}" role="button" tabindex="0"
aria-label="阅读公告: ${annEscapeHtml(n.name)}">
<div class="meta-row">
<span class="date">${annEscapeHtml(annFormatDate(n.date))}</span>
<span class="dot"></span>
<span class="badge ${annEscapeHtml(pri)}">${annEscapeHtml(n.priority_label || pri)}</span>
</div>
<div class="title">${annEscapeHtml(n.name)}</div>
<div class="preview">${annEscapeHtml(preview)}</div>
<div class="more">阅读全文 <span class="arrow"></span></div>
</div>`;
}).join('');
root.querySelectorAll('.announce-card').forEach(card => {
const idx = +card.dataset.idx;
const open = () => openAnnounceModal(list[idx]);
card.addEventListener('click', open);
card.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); open(); }
});
});
}
// ---- 弹窗(懒创建一次) ----
let _annModal = null;
let _annLastFocus = null;
function ensureAnnounceModal() {
if (_annModal) return _annModal;
_annModal = document.createElement('div');
_annModal.className = 'announce-modal';
_annModal.setAttribute('role', 'presentation');
_annModal.innerHTML = `
<div class="backdrop" data-close="1" aria-label="关闭"></div>
<div class="dialog" role="dialog" aria-modal="true" aria-labelledby="ann-modal-title">
<div class="head">
<div>
<div class="title" id="ann-modal-title" data-title></div>
<div class="meta" data-meta></div>
</div>
<button class="close" type="button" data-close="1" aria-label="关闭">×</button>
</div>
<div class="body" data-body></div>
</div>`;
document.body.appendChild(_annModal);
_annModal.addEventListener('click', e => {
if (e.target.closest('[data-close]')) closeAnnounceModal();
});
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && _annModal.classList.contains('open')) closeAnnounceModal();
});
return _annModal;
}
function openAnnounceModal(item) {
if (!item) return;
const m = ensureAnnounceModal();
m.querySelector('[data-title]').textContent = item.name || '';
m.querySelector('[data-meta]').textContent =
[annFormatDate(item.date), item.priority_label].filter(Boolean).join(' · ');
// body_html 来自后台管理员撰写可信非管理员输入路径不存在OK 直接渲染
m.querySelector('[data-body]').innerHTML = item.body_html || '<p style="color:#556677">(无正文)</p>';
_annLastFocus = document.activeElement;
m.classList.add('open');
document.body.style.overflow = 'hidden';
// 焦点移入弹窗(关闭按钮)
const closeBtn = m.querySelector('.close');
if (closeBtn) closeBtn.focus({ preventScroll: true });
}
function closeAnnounceModal() {
if (!_annModal || !_annModal.classList.contains('open')) return;
_annModal.classList.remove('open');
document.body.style.overflow = '';
if (_annLastFocus && _annLastFocus.focus) _annLastFocus.focus({ preventScroll: true });
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {

View File

@ -144,282 +144,3 @@
.login-footer a:hover { .login-footer a:hover {
text-decoration: underline; text-decoration: underline;
} }
/* ==================================================
公告页 · 大气版卡片 + 弹窗
================================================== */
/* ---- 页面头 ---- */
#panel-announce h1 {
font-size: 44px;
letter-spacing: 8px;
margin-bottom: 10px;
text-align: center;
}
#panel-announce .page-sub {
text-align: center;
font-size: 13px;
letter-spacing: 5px;
color: #6b8aa8;
display: block;
margin: 0 auto 36px;
position: relative;
padding: 0 32px;
width: max-content;
}
#panel-announce .page-sub::before,
#panel-announce .page-sub::after {
content: "";
position: absolute;
top: 50%;
width: 36px;
height: 1px;
background: linear-gradient(90deg, transparent, #4af, transparent);
opacity: .7;
}
#panel-announce .page-sub::before { right: 100%; }
#panel-announce .page-sub::after { left: 100%; }
/* ---- 卡片列表 ---- */
.announce-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.announce-card {
position: relative;
background: linear-gradient(135deg, rgba(20, 28, 48, 0.72), rgba(12, 18, 32, 0.72));
border: 1px solid rgba(120, 140, 200, 0.14);
border-left: 3px solid #a78bfa;
border-radius: 12px;
padding: 22px 28px 22px 26px;
cursor: pointer;
overflow: hidden;
transition: transform .22s ease, border-color .22s, box-shadow .22s, background .22s;
outline: none;
}
.announce-card::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(600px circle at 0% 0%, rgba(167, 139, 250, 0.10), transparent 45%);
pointer-events: none;
opacity: 0;
transition: opacity .25s;
}
.announce-card:hover,
.announce-card:focus-visible {
transform: translateY(-2px);
border-color: rgba(167, 139, 250, 0.4);
box-shadow: 0 14px 38px -18px rgba(167, 139, 250, 0.55),
0 0 0 1px rgba(167, 139, 250, 0.12);
}
.announce-card:hover::before,
.announce-card:focus-visible::before { opacity: 1; }
.announce-card.priority-important { border-left-color: #fbbf24; }
.announce-card.priority-important:hover,
.announce-card.priority-important:focus-visible {
box-shadow: 0 14px 38px -18px rgba(251, 191, 36, 0.5),
0 0 0 1px rgba(251, 191, 36, 0.14);
}
.announce-card.priority-urgent {
border-left-color: #f87171;
background: linear-gradient(135deg, rgba(42, 20, 30, 0.72), rgba(22, 12, 18, 0.72));
}
.announce-card.priority-urgent:hover,
.announce-card.priority-urgent:focus-visible {
box-shadow: 0 14px 38px -18px rgba(248, 113, 113, 0.55),
0 0 0 1px rgba(248, 113, 113, 0.18);
}
.announce-card .meta-row {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: #6b7d92;
letter-spacing: 1px;
margin-bottom: 10px;
position: relative;
}
.announce-card .date { font-variant-numeric: tabular-nums; }
.announce-card .dot {
width: 3px; height: 3px; border-radius: 50%;
background: #4a5568;
}
.announce-card .badge {
font-size: 11px;
padding: 2px 8px;
border-radius: 4px;
letter-spacing: 1.5px;
border: 1px solid currentColor;
}
.announce-card .badge.normal { color: #6b7d92; }
.announce-card .badge.important { color: #fbbf24; background: rgba(251, 191, 36, 0.08); }
.announce-card .badge.urgent { color: #f87171; background: rgba(248, 113, 113, 0.10); }
.announce-card .title {
font-size: 18px;
font-weight: 600;
color: #e8eef7;
line-height: 1.55;
margin-bottom: 8px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.announce-card .preview {
font-size: 14px;
color: #8b9bb2;
line-height: 1.75;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
margin-bottom: 12px;
}
.announce-card .more {
font-size: 13px;
color: #a78bfa;
letter-spacing: 1.5px;
display: inline-flex;
align-items: center;
gap: 4px;
transition: gap .2s;
}
.announce-card:hover .more,
.announce-card:focus-visible .more { gap: 8px; }
.announce-card .more .arrow { transition: transform .2s; display: inline-block; }
.announce-card:hover .more .arrow,
.announce-card:focus-visible .more .arrow { transform: translateX(3px); }
.announce-empty {
text-align: center;
padding: 60px 20px;
color: #556677;
font-size: 15px;
}
/* ---- 弹窗 ---- */
.announce-modal {
position: fixed;
inset: 0;
z-index: 2000;
display: none;
align-items: center;
justify-content: center;
padding: 40px 20px;
}
.announce-modal.open {
display: flex;
animation: annFade .2s ease;
}
@keyframes annFade { from { opacity: 0; } to { opacity: 1; } }
.announce-modal .backdrop {
position: absolute;
inset: 0;
background: rgba(4, 7, 14, 0.72);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
cursor: pointer;
}
.announce-modal .dialog {
position: relative;
width: 100%;
max-width: 720px;
max-height: calc(100vh - 80px);
background: linear-gradient(160deg, #0e1626, #0a1020);
border: 1px solid rgba(167, 139, 250, 0.22);
border-radius: 16px;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.7),
0 0 60px -20px rgba(167, 139, 250, 0.3);
display: flex;
flex-direction: column;
animation: annPop .28s cubic-bezier(0.2, 0.9, 0.3, 1.2);
overflow: hidden;
}
@keyframes annPop {
from { opacity: 0; transform: translateY(20px) scale(0.96); }
to { opacity: 1; transform: none; }
}
.announce-modal .head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 28px 32px 20px;
border-bottom: 1px solid rgba(120, 140, 200, 0.12);
flex-shrink: 0;
gap: 16px;
}
.announce-modal .head .title {
font-size: 22px;
font-weight: 600;
color: #f0f4fb;
line-height: 1.5;
margin-bottom: 8px;
padding-right: 8px;
}
.announce-modal .head .meta {
font-size: 12px;
color: #6b7d92;
letter-spacing: 1.5px;
}
.announce-modal .close {
width: 34px;
height: 34px;
border-radius: 8px;
background: rgba(120, 140, 200, 0.08);
border: none;
color: #8b9bb2;
font-size: 20px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
transition: background .2s, color .2s;
}
.announce-modal .close:hover {
background: rgba(167, 139, 250, 0.18);
color: #a78bfa;
}
.announce-modal .body {
padding: 24px 32px 32px;
overflow-y: auto;
font-size: 15px;
line-height: 1.9;
color: #c0cad9;
}
.announce-modal .body p { color: #c0cad9; font-size: 15px; margin-bottom: 12px; line-height: 1.9; }
.announce-modal .body p:last-child { margin-bottom: 0; }
.announce-modal .body strong { color: #e8eef7; }
.announce-modal .body a { color: #a78bfa; }
.announce-modal .body h1,
.announce-modal .body h2,
.announce-modal .body h3 { color: #e8eef7; margin: 18px 0 10px; }
.announce-modal .body ul,
.announce-modal .body ol { padding-left: 22px; margin-bottom: 12px; }
.announce-modal .body li { margin-bottom: 4px; }
.announce-modal .body code {
background: rgba(120, 140, 200, 0.12);
padding: 1px 6px;
border-radius: 4px;
font-size: 13px;
}
/* ---- 响应式 ---- */
@media (max-width: 768px) {
#panel-announce h1 { font-size: 34px; letter-spacing: 5px; }
#panel-announce .page-sub { font-size: 12px; letter-spacing: 4px; margin-bottom: 28px; }
.announce-card { padding: 18px 18px 18px 20px; }
.announce-card .title { font-size: 16px; }
.announce-card .preview { font-size: 13px; }
.announce-modal { padding: 16px 12px; }
.announce-modal .head { padding: 20px 20px 14px; }
.announce-modal .head .title { font-size: 18px; }
.announce-modal .body { padding: 18px 20px 24px; font-size: 14px; }
}

View File

@ -259,8 +259,8 @@ p { color:#99aabb; font-size:15px; margin-bottom:14px; line-height:1.9; }
<!-- ========== 公告 ========== --> <!-- ========== 公告 ========== -->
<div class="tab-panel" id="panel-announce"> <div class="tab-panel" id="panel-announce">
<h1>公 告</h1> <h1>公 告</h1>
<p class="page-sub">官方通知 · ANNOUNCEMENTS</p> <p class="page-sub">官方通知与更新日志</p>
<div id="announce" class="announce-list" aria-live="polite"></div> <div id="announce"><div class="loading">加载中...</div></div>
</div> </div>
<!-- ========== 剧情(新增) ========== --> <!-- ========== 剧情(新增) ========== -->

View File

@ -214,26 +214,13 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
.status-moving { color: #4af; } .status-moving { color: #4af; }
/* 飞入遮罩 —— 底部提示药丸 + 全宽进度条 */ /* 飞入遮罩 —— 底部提示药丸 + 全宽进度条 */
/* ===== 飞入过渡:随机动漫插画作背景 + 进度条/提示在前景 ===== */
#flyInOverlay { #flyInOverlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: transparent; z-index: 300; background: #0a0e17; z-index: 300;
display: flex; flex-direction: column; justify-content: flex-end; display: flex; flex-direction: column; justify-content: flex-end;
padding: 0 24px 48px 24px; padding: 0 24px 48px 24px;
opacity: 0; visibility: hidden; pointer-events: none; opacity: 0; visibility: hidden; pointer-events: none;
} }
/* 背景插画层:铺满 + 放大遮水印,置于内容之下 */
#flyInOverlay .flyBg {
position: absolute; inset: 0; width: 100%; height: 100%;
object-fit: cover; transform: scale(1.08);
z-index: 0;
}
/* 深色压暗层,保证前景文字/进度条可读 */
#flyInOverlay::before {
content: ''; position: absolute; inset: 0; z-index: 1;
background: linear-gradient(180deg, rgba(10,14,23,.35) 0%, rgba(10,14,23,.65) 100%);
}
#flyInOverlay > *:not(.flyBg) { position: relative; z-index: 2; }
#flyInOverlay.active { opacity: 1; visibility: visible; pointer-events: auto; } #flyInOverlay.active { opacity: 1; visibility: visible; pointer-events: auto; }
#flyInOverlay .flyLine { #flyInOverlay .flyLine {
display: flex; align-items: center; gap: 14px; display: flex; align-items: center; gap: 14px;
@ -410,25 +397,16 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
#navControls .nc-down{grid-column:2;grid-row:3;} #navControls .nc-down{grid-column:2;grid-row:3;}
#navControls .nc-se{grid-column:3;grid-row:3;} #navControls .nc-se{grid-column:3;grid-row:3;}
/* ===== 登录闸门(小型居中转圈,不遮挡主屏幕) ===== */ /* ===== 登录闸门 ===== */
#authSplash{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center; #authSplash{position:fixed;inset:0;z-index:9999;display:flex;align-items:center;justify-content:center;
background:rgba(8,12,26,.45);backdrop-filter:blur(4px);color:#cdd8e6; background:linear-gradient(160deg,#080c1a,#0e1730 48%,#15213f);color:#9aa6c4;
font-family:"Noto Sans SC",system-ui,sans-serif;font-size:13px;letter-spacing:2px;} font-family:"Noto Sans SC",system-ui,sans-serif;font-size:15px;letter-spacing:3px;}
#authSplash .auth-spinner{
display:flex;flex-direction:column;align-items:center;gap:14px;
padding:28px 36px;border-radius:16px;
background:rgba(14,23,48,.75);border:1px solid rgba(139,92,246,.25);
box-shadow:0 8px 32px rgba(0,0,0,.5);}
#authSpinner{width:32px;height:32px;border:3px solid rgba(139,92,246,.25);
border-top-color:#a78bfa;border-radius:50%;
animation:authSpin .7s linear infinite;}
@keyframes authSpin{to{transform:rotate(360deg);}}
/* #authBar 已融合进 #topBar .userArea */ /* #authBar 已融合进 #topBar .userArea */
</style> </style>
</head> </head>
<body> <body>
<div id="authSplash"><div class="auth-spinner"><div id="authSpinner"></div><span>登录校验中…</span></div></div> <div id="authSplash">登录校验中…</div>
<!-- 加载界面 --> <!-- 加载界面 -->
<div id="loadingScreen"> <div id="loadingScreen">
@ -444,6 +422,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<div id="topBar"> <div id="topBar">
<span class="title"><img src="assets/ui/logo_planet_48.png" alt=""><span class="brand">宇森</span><span class="worldName" id="worldName">大地</span></span> <span class="title"><img src="assets/ui/logo_planet_48.png" alt=""><span class="brand">宇森</span><span class="worldName" id="worldName">大地</span></span>
<button id="buildToggle">🔨 建造</button>
<div class="topRight"> <div class="topRight">
<span class="coords" id="coords">坐标: (0, 0)</span> <span class="coords" id="coords">坐标: (0, 0)</span>
<div class="userArea" id="userArea" hidden> <div class="userArea" id="userArea" hidden>
@ -464,11 +443,14 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<!-- 主沙盘底部功能框架(预留区,放功能按钮 / 素材槽) --> <!-- 主沙盘底部功能框架(预留区,放功能按钮 / 素材槽) -->
<div id="bottomBar"> <div id="bottomBar">
<span class="bb-label">功能栏</span>
<div class="bb-slots"> <div class="bb-slots">
<button class="bb-btn" id="btnData" type="button">📊 数据</button> <div class="bb-slot bb-slot--empty"></div>
<button class="bb-btn" id="btnInventory" type="button">🎒 包裹</button> <div class="bb-slot bb-slot--empty"></div>
<button class="bb-btn" id="btnBuild" type="button">🔨 建造</button> <div class="bb-slot bb-slot--empty"></div>
<button class="bb-btn" id="btnTech" type="button">🔬 科技</button> <div class="bb-slot bb-slot--empty"></div>
<div class="bb-slot bb-slot--empty"></div>
<div class="bb-slot bb-slot--empty"></div>
</div> </div>
</div> </div>
@ -528,7 +510,7 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<img id="sceneImage" style="display:none;" alt=""> <img id="sceneImage" style="display:none;" alt="">
<div id="dataPanel" style="display:none;"> <div id="dataPanel">
<div id="dpHeader"><span id="dpTitle">面板</span><button id="dpClose">关闭</button></div> <div id="dpHeader"><span id="dpTitle">面板</span><button id="dpClose">关闭</button></div>
<div id="dpBody"></div> <div id="dpBody"></div>
</div> </div>
@ -536,7 +518,6 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<!-- 飞入过渡 --> <!-- 飞入过渡 -->
<div id="flyInOverlay"> <div id="flyInOverlay">
<img class="flyBg" id="flyBg" alt="">
<div class="flyTip" id="flyTip">💡 拖拽空白处可平移视角,滚轮缩放沙盘</div> <div class="flyTip" id="flyTip">💡 拖拽空白处可平移视角,滚轮缩放沙盘</div>
<div class="flyLine"> <div class="flyLine">
<div class="flyBar"><div class="flyFill" id="flyFill"></div></div> <div class="flyBar"><div class="flyFill" id="flyFill"></div></div>
@ -595,71 +576,11 @@ body { background: #0a0e17; overflow: hidden; font-family: 'Microsoft YaHei', sa
<!-- 背景切换面板 --> <!-- 背景切换面板 -->
<style> <style>
#btnBuild{background:rgba(100,200,255,.12);border:1px solid rgba(100,200,255,.3);color:#9fd;padding:9px 14px;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;pointer-events:auto;transition:.2s;box-shadow:0 4px 16px rgba(80,120,255,.35);} #buildToggle{margin-left:14px;background:rgba(100,200,255,.12);border:1px solid rgba(100,200,255,.3);color:#9fd;padding:7px 14px;border-radius:8px;font-size:14px;cursor:pointer;pointer-events:auto;transition:.2s;}
#btnBuild:hover{filter:brightness(1.12);} #buildToggle:hover{background:rgba(100,200,255,.22);}
#btnBuild.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,170,255,.5);} #buildToggle.active{background:linear-gradient(135deg,#8b5cf6,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,170,255,.5);}
#btnTech{background:rgba(120,255,200,.12);border:1px solid rgba(120,255,200,.3);color:#9ff;padding:9px 14px;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;pointer-events:auto;transition:.2s;box-shadow:0 4px 16px rgba(80,255,180,.3);} #buildPanel{position:fixed;left:16px;top:64px;z-index:60;width:440px;max-height:82vh;overflow-y:auto;background:linear-gradient(180deg,rgba(12,16,28,.98),rgba(16,24,44,.98));border:1px solid rgba(120,200,255,.28);border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.55);color:#e8eef6;font-size:15px;padding:20px;backdrop-filter:blur(8px);}
#btnTech:hover{filter:brightness(1.12);} #buildPanel.hidden{display:none;}
#btnTech.active{background:linear-gradient(135deg,#10b981,#4aafff);color:#fff;border-color:transparent;box-shadow:0 0 14px rgba(100,220,180,.5);}
.bb-btn.active{outline:2px solid rgba(255,255,255,.75);box-shadow:0 0 18px rgba(140,180,255,.6);}
/* ===== 统一左侧面板(数据 / 包裹 / 建造 三合一) ===== */
#sidePanel{
position:fixed;top:58px;left:0;bottom:72px;width:380px;z-index:150;
background:linear-gradient(180deg,rgba(10,14,23,.97),rgba(14,22,40,.97));
border-right:1px solid rgba(100,200,255,.22);
display:flex;flex-direction:column;
transform:translateX(-100%);transition:transform .28s ease;
box-shadow:8px 0 40px rgba(0,0,0,.45);
}
#sidePanel.open{transform:translateX(0);}
#spTabs{display:flex;gap:0;flex-shrink:0;border-bottom:1px solid rgba(100,200,255,.15);
background:linear-gradient(180deg,rgba(12,18,32,.98),rgba(8,12,22,.95));}
.sp-tab{flex:1;padding:12px 6px;background:none;border:none;color:#8899aa;font-size:14px;font-weight:600;
cursor:pointer;transition:.18s;border-bottom:2px solid transparent;text-align:center;}
.sp-tab:hover{color:#cfe6ff;background:rgba(100,200,255,.06);}
.sp-tab.active{color:#fff;border-bottom-color:#8b5cf6;background:rgba(139,92,246,.10);}
.sp-tab .sp-ico{display:block;font-size:18px;margin-bottom:3px;}
.sp-body{flex:1;overflow-y:auto;overflow-x:hidden;padding:14px 16px;display:none;min-height:0;}
.sp-body.active{display:block;}
.sp-body::-webkit-scrollbar{width:4px;}
.sp-body::-webkit-scrollbar-thumb{background:rgba(100,200,255,.28);border-radius:2px;}
/* 沙盘左移(给侧面板让路) */
#gameCanvas{transition:transform .28s ease,left .28s ease,width .28s ease;}
body.side-open #gameCanvas{transform:translateX(380px);}
/* ----- 包裹7列 × 行(物资分类 + 小格子 + 滚动条) ----- */
.inv-cat{font-size:11px;color:#8b5cf6;letter-spacing:1.5px;text-transform:uppercase;margin:14px 0 6px;
padding-bottom:4px;border-bottom:1px solid rgba(139,92,246,.2);}
.inv-cat:first-child{margin-top:0;}
.inv-grid-7{display:grid;grid-template-columns:repeat(7,1fr);gap:3px;}
.inv-slot{
aspect-ratio:1;border-radius:5px;display:flex;align-items:center;justify-content:center;
font-size:15px;color:#56708c;background:rgba(255,255,255,.04);
border:1px dashed rgba(120,200,255,.18);cursor:pointer;transition:.15s;position:relative;
}
.inv-slot:hover{border-color:rgba(120,200,255,.5);background:rgba(120,200,255,.08);color:#cfe6ff;}
.inv-slot.has-item{border-style:solid;border-color:rgba(120,200,255,.25);background:rgba(120,200,255,.06);}
.inv-slot .inv-ct{position:absolute;bottom:1px;right:2px;font-size:8px;color:#8aa0b8;line-height:1;
background:rgba(0,0,0,.45);padding:0 3px;border-radius:3px;}
/* ----- 建造嵌入侧面板保留4子Tab ----- */
#buildPanel{position:static;width:auto;max-height:none;overflow:visible;
background:transparent;border:none;border-radius:0;box-shadow:none;padding:0;
display:none;height:100%;flex-direction:column;}
#buildPanel.active{display:flex;}
.bp-head{margin-bottom:10px;padding-bottom:8px;border-bottom:1px solid rgba(100,200,255,.12);}
.bp-tools{gap:4px;margin-bottom:10px;}
.bp-tool{padding:7px 2px;font-size:12px;}
.bp-brush{gap:4px;min-height:32px;}
.bp-chip{padding:6px 9px;font-size:12px;}
.bp-hint{font-size:13px;margin-bottom:10px;}
.bp-hctl{margin-bottom:10px;}
.bp-hctl-row{font-size:13px;}
.bp-flatten,.bp-manage{padding:9px;font-size:13px;}
.bp-mgmt{gap:10px;margin-bottom:10px;}
.bp-variants{max-height:160px;grid-template-columns:repeat(4,1fr);}
.bp-head{display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:17px;margin-bottom:14px;} .bp-head{display:flex;justify-content:space-between;align-items:center;font-weight:700;font-size:17px;margin-bottom:14px;}
#bpClose{background:none;border:none;color:#9fb0c4;font-size:22px;cursor:pointer;} #bpClose{background:none;border:none;color:#9fb0c4;font-size:22px;cursor:pointer;}
#bpClose:hover{color:#fff;} #bpClose:hover{color:#fff;}
@ -695,117 +616,38 @@ body.side-open #gameCanvas{transform:translateX(380px);}
.bp-mgmt-presets button:hover{background:rgba(139,92,246,.3);} .bp-mgmt-presets button:hover{background:rgba(139,92,246,.3);}
#bpMgmtApply{background:linear-gradient(135deg,#8b5cf6,#4aafff);border:none;color:#fff;border-radius:11px;padding:13px;font-weight:700;cursor:pointer;font-size:15px;transition:.2s;} #bpMgmtApply{background:linear-gradient(135deg,#8b5cf6,#4aafff);border:none;color:#fff;border-radius:11px;padding:13px;font-weight:700;cursor:pointer;font-size:15px;transition:.2s;}
#bpMgmtApply:hover{filter:brightness(1.08);} #bpMgmtApply:hover{filter:brightness(1.08);}
/* 包裹面板(复用 buildPanel 弹窗风格,从左侧滑出) */
#inventoryPanel{position:fixed;left:16px;top:64px;z-index:60;width:440px;max-height:82vh;overflow-y:auto;background:linear-gradient(180deg,rgba(12,16,28,.98),rgba(16,24,44,.98));border:1px solid rgba(120,200,255,.28);border-radius:16px;box-shadow:0 12px 40px rgba(0,0,0,.55);color:#e8eef6;font-size:15px;padding:20px;backdrop-filter:blur(8px);}
#inventoryPanel.hidden{display:none;}
.inv-hint{font-size:12px;color:#8aa0b8;margin-bottom:14px;line-height:1.5;}
.demo-tag{display:inline-block;background:rgba(255,180,80,.18);border:1px solid rgba(255,180,80,.4);color:#ffcf86;font-size:11px;padding:1px 7px;border-radius:6px;margin-left:4px;}
.inv-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;}
.inv-item{background:rgba(255,255,255,.04);border:1px solid rgba(120,200,255,.18);border-radius:10px;padding:10px;display:flex;flex-direction:column;align-items:center;gap:6px;transition:.15s;}
.inv-item:hover{border-color:rgba(120,200,255,.5);background:rgba(120,200,255,.08);}
.inv-item .ico{font-size:26px;}
.inv-item .nm{font-size:12px;color:#dce6f2;text-align:center;}
.inv-item .ct{font-size:11px;color:#8aa0b8;}
.inv-item .ct.limited{color:#ffcf86;}
/* 数据面板子项 */
#dpBody .dp-section{margin-bottom:18px;}
#dpBody .dp-h{font-size:12px;color:#7f93a8;text-transform:uppercase;letter-spacing:1px;margin-bottom:8px;border-bottom:1px solid rgba(120,200,255,.15);padding-bottom:5px;}
#dpBody .dp-row{display:flex;justify-content:space-between;font-size:14px;color:#dbe6f2;padding:5px 0;border-bottom:1px dashed rgba(255,255,255,.06);}
#dpBody .dp-note{font-size:12px;color:#8aa0b8;margin-top:10px;line-height:1.5;}
#dpBody code{background:rgba(255,255,255,.08);padding:1px 5px;border-radius:4px;font-size:11px;}
/* ===== 科技树(演示 + 预留 /yt_world/api/tech ===== */
.tech-pool{display:flex;align-items:center;justify-content:space-between;gap:8px;background:rgba(16,185,129,.1);
border:1px solid rgba(16,185,129,.3);border-radius:10px;padding:9px 12px;margin-bottom:14px;}
.tech-pool .tp-label{font-size:12px;color:#9ff;letter-spacing:1px;}
.tech-pool .tp-val{font-size:20px;font-weight:800;color:#5eead4;}
.tech-pool .tp-reset{background:none;border:1px solid rgba(255,255,255,.18);color:#9fb0c4;border-radius:7px;
padding:4px 9px;font-size:11px;cursor:pointer;transition:.15s;}
.tech-pool .tp-reset:hover{color:#fff;border-color:rgba(255,120,120,.5);}
.tech-tier{margin-bottom:16px;}
.tech-tier-h{font-size:11px;color:#10b981;letter-spacing:1.5px;text-transform:uppercase;margin:0 0 8px;
padding-bottom:4px;border-bottom:1px solid rgba(16,185,129,.22);}
.tech-nodes{display:flex;flex-wrap:wrap;gap:8px;}
.tech-node{position:relative;width:calc(50% - 4px);box-sizing:border-box;background:rgba(255,255,255,.04);
border:1px solid rgba(120,255,200,.18);border-radius:11px;padding:10px 11px;cursor:default;transition:.18s;}
.tech-node:hover{border-color:rgba(120,255,200,.45);background:rgba(120,255,200,.07);}
.tech-node .tn-top{display:flex;align-items:center;gap:7px;margin-bottom:5px;}
.tech-node .tn-ico{font-size:20px;}
.tech-node .tn-name{font-size:13px;font-weight:700;color:#e8f4ef;}
.tech-node .tn-desc{font-size:11px;color:#9fb0c4;line-height:1.45;margin-bottom:6px;min-height:30px;}
.tech-node .tn-pre{font-size:10px;color:#7f93a8;margin-bottom:7px;}
.tech-node .tn-pre b{color:#ffb454;font-weight:600;}
.tech-node .tn-pre .met{color:#5eead4;}
.tech-node .tn-cost{font-size:11px;color:#9ff;font-weight:700;}
.tech-node .tn-btn{width:100%;margin-top:7px;border:none;border-radius:8px;padding:7px;font-size:12px;font-weight:700;
cursor:pointer;transition:.15s;background:rgba(16,185,129,.18);color:#5eead4;border:1px solid rgba(16,185,129,.4);}
.tech-node .tn-btn:hover{filter:brightness(1.15);}
.tech-node.locked{opacity:.5;}
.tech-node.locked .tn-btn{background:rgba(255,255,255,.05);color:#7f93a8;border-color:rgba(255,255,255,.12);cursor:not-allowed;}
.tech-node.done{background:rgba(16,185,129,.14);border-color:rgba(16,185,129,.55);}
.tech-node.done .tn-btn{background:rgba(16,185,129,.28);color:#d6fff2;cursor:default;}
.tech-node.afford .tn-btn{background:linear-gradient(135deg,#10b981,#4aafff);color:#fff;border-color:transparent;
box-shadow:0 0 10px rgba(16,185,129,.35);}
.tech-note{font-size:11px;color:#8aa0b8;margin-top:8px;line-height:1.5;}
</style> </style>
<!-- ===== 统一左侧面板(数据 / 包裹 / 建造) ===== --> <div id="buildPanel" class="hidden">
<div id="sidePanel"> <div class="bp-head"><span>🔨 建造</span><button id="bpClose" aria-label="关闭">×</button></div>
<div id="spTabs"> <div class="bp-tools">
<button class="sp-tab" data-sp="data"><span class="sp-ico">📊</span>数据</button> <button class="bp-tool active" data-tool="terrain">地形</button>
<button class="sp-tab" data-sp="inventory"><span class="sp-ico">🎒</span>包裹</button> <button class="bp-tool" data-tool="move">移动</button>
<button class="sp-tab" data-sp="build"><span class="sp-ico">🔨</span>建造</button> <button class="bp-tool" data-tool="height">高度</button>
<button class="sp-tab" data-sp="tech"><span class="sp-ico">🔬</span>科技</button> <button class="bp-tool" data-tool="manage">管理</button>
</div> </div>
<!-- 数据 Tab --> <div class="bp-brush" id="bpBrush"></div>
<div class="sp-body" id="spDataBody"></div> <div class="bp-hctl" id="bpHeightControl" style="display:none;">
<!-- 包裹 Tab7×50 格 + 物资分类) --> <div class="bp-hctl-row">
<div class="sp-body" id="spInvBody"> <span>高度</span>
<div class="inv-cat">笔刷 · Brush</div> <input type="range" id="bpHeightSlider" min="1" max="12" step="1" value="3">
<div class="inv-grid-7" id="invGridBrush"></div> <span id="bpHeightVal">3 · 森林</span>
<div class="inv-cat">模板 · Template</div> </div>
<div class="inv-grid-7" id="invGridTemplate"></div> <button id="bpFlatten" class="bp-flatten">⤓ 平铺到最低高度</button>
<div class="inv-cat">事件卡 · Event</div>
<div class="inv-grid-7" id="invGridEvent"></div>
</div> </div>
<!-- 建造 Tab4 子Tab移动/地形/高度/管理) --> <div class="bp-mgmt" id="bpMgmt" style="display:none;">
<div class="sp-body" id="spBuildBody"> <div class="bp-mgmt-body" id="bpMgmtBody"></div>
<div id="buildPanel"> <div class="bp-mgmt-foot">
<div class="bp-head"><span>🔨 建造工具</span></div> <div class="bp-mgmt-presets">
<div class="bp-tools"> <span>预设</span>
<button class="bp-tool active" data-tool="terrain">地形</button> <button data-preset="default">默认</button>
<button class="bp-tool" data-tool="move">移动</button> <button data-preset="island">小岛</button>
<button class="bp-tool" data-tool="height">高度</button> <button data-preset="peaks">雪峰</button>
<button class="bp-tool" data-tool="manage">管理</button> <button data-preset="water">水乡</button>
</div> </div>
<div class="bp-brush" id="bpBrush"></div> <button id="bpMgmtApply">应用</button>
<div class="bp-hctl" id="bpHeightControl" style="display:none;">
<div class="bp-hctl-row">
<span>高度</span>
<input type="range" id="bpHeightSlider" min="1" max="12" step="1" value="3">
<span id="bpHeightVal">3 · 森林</span>
</div>
<button id="bpFlatten" class="bp-flatten">⤓ 平铺到最低高度</button>
</div>
<div class="bp-mgmt" id="bpMgmt" style="display:none;">
<div class="bp-mgmt-body" id="bpMgmtBody"></div>
<div class="bp-mgmt-foot">
<div class="bp-mgmt-presets">
<span>预设</span>
<button data-preset="default">默认</button>
<button data-preset="island">小岛</button>
<button data-preset="peaks">雪峰</button>
<button data-preset="water">水乡</button>
</div>
<button id="bpMgmtApply">应用</button>
</div>
</div>
<div class="bp-hint" id="bpHint"></div>
</div> </div>
</div> </div>
<!-- 科技树 Tab --> <div class="bp-hint" id="bpHint"></div>
<div class="sp-body" id="spTechBody"></div>
</div> </div>
<!-- 单位详情弹窗 --> <!-- 单位详情弹窗 -->
@ -1952,34 +1794,11 @@ function drawTimeAmbiance(pal) {
} }
} }
// ============ 随机过场插画(飞入过渡背景用) ============
const TRANSITION_IMAGES = [
'assets/art/transitions/transition_dungeon.png',
'assets/art/transitions/transition_adventure.png',
'assets/art/transitions/transition_sky.png',
'assets/art/transitions/transition_forest.png',
'assets/art/transitions/transition_team.png',
'assets/art/transitions/transition_night.png',
'assets/art/transitions/transition_dawn.png',
'assets/art/transitions/transition_coast.png',
'assets/art/transitions/transition_skycity.png',
];
let _lastTransitionIdx = -1; // 避免连续两次同一张
function pickTransitionImage() {
let idx;
do { idx = Math.floor(Math.random() * TRANSITION_IMAGES.length); } while (idx === _lastTransitionIdx && TRANSITION_IMAGES.length > 1);
_lastTransitionIdx = idx;
return TRANSITION_IMAGES[idx];
}
// ============ 进入微观世界 ============ // ============ 进入微观世界 ============
function enterMicroWorld(tile) { function enterMicroWorld(tile) {
if (!tile) return; if (!tile) return;
const overlay = document.getElementById('flyInOverlay'); const overlay = document.getElementById('flyInOverlay');
const microView = document.getElementById('microView'); const microView = document.getElementById('microView');
// 0) 随机图作飞入遮罩背景(铺满 + 放大遮水印),进度条/提示仍在前景
const flyBg = document.getElementById('flyBg');
if (flyBg) flyBg.src = pickTransitionImage();
// 1) 先盖屏,立刻在遮罩下把微观世界建好并跑起来:重活消化在幕后, // 1) 先盖屏,立刻在遮罩下把微观世界建好并跑起来:重活消化在幕后,
// 揭幕那一帧不再有主线程尖峰 → 消除"加载完却一顿"的卡顿 // 揭幕那一帧不再有主线程尖峰 → 消除"加载完却一顿"的卡顿
overlay.classList.add('active'); overlay.classList.add('active');
@ -1987,8 +1806,8 @@ function enterMicroWorld(tile) {
const flyPct = document.getElementById('flyPct'); const flyPct = document.getElementById('flyPct');
if (flyFill) flyFill.style.width = '0%'; if (flyFill) flyFill.style.width = '0%';
if (flyPct) flyPct.textContent = '0%'; if (flyPct) flyPct.textContent = '0%';
// 进度条动画:与最短停留时长保持同步,给玩家明确的加载反馈 // 进度条动画:与 1 秒保持同步,给玩家明确的加载反馈
const MICRO_ENTER_HOLD = 1100; // [PLACEHOLDER] 体感测试:最短 1.1s,快机可调到 900 const MICRO_ENTER_HOLD = 1000; // [PLACEHOLDER] 体感测试1s 是否够?快机可调到 700
const startT = performance.now(); const startT = performance.now();
let rafId; let rafId;
function tick(now) { function tick(now) {
@ -2018,7 +1837,7 @@ function enterMicroWorld(tile) {
updateSidebar(); updateSidebar();
initMicroCanvas(); initMicroCanvas();
startMicroRender(); startMicroRender();
// 2) 至少保持最短时长(遮罩掩护重活 + 入场动画兜底),到点纯淡出揭幕:无尖峰、无 pop-in // 2) 至少保持 1 秒(遮罩掩护重活 + 入场动画兜底),到点纯淡出揭幕:无尖峰、无 pop-in
setTimeout(() => { setTimeout(() => {
if (rafId) cancelAnimationFrame(rafId); if (rafId) cancelAnimationFrame(rafId);
if (flyFill) flyFill.style.width = '100%'; if (flyFill) flyFill.style.width = '100%';
@ -2960,7 +2779,7 @@ function swapTiles(a, b) {
// 建造模式 UI 绑定 // 建造模式 UI 绑定
function setupBuildUI() { function setupBuildUI() {
const toggle = document.getElementById('btnBuild'); const toggle = document.getElementById('buildToggle');
const panel = document.getElementById('buildPanel'); const panel = document.getElementById('buildPanel');
const bpClose = document.getElementById('bpClose'); const bpClose = document.getElementById('bpClose');
const bpBrush = document.getElementById('bpBrush'); const bpBrush = document.getElementById('bpBrush');
@ -3030,269 +2849,10 @@ function setupBuildUI() {
renderBrush(); renderBrush();
}; };
bpClose.onclick = () => { bpClose.onclick = () => {
buildMode = false; toggle.classList.remove('active'); panel.classList.remove('active'); buildMode = false; toggle.classList.remove('active'); panel.classList.add('hidden');
swapFirst = null; draggingSwap = false; dragSwapFrom = null; swapFirst = null; draggingSwap = false; dragSwapFrom = null;
closeSidePanel();
}; };
// ===== 底部三按钮 → 统一左侧面板(数据/包裹/建造 Tab 切换) =====
const btnData = document.getElementById('btnData');
const btnInventory = document.getElementById('btnInventory');
const btnBuild = document.getElementById('btnBuild');
const btnTech = document.getElementById('btnTech');
const sidePanel = document.getElementById('sidePanel');
const spTabs = document.querySelectorAll('#spTabs .sp-tab');
const spBodies = document.querySelectorAll('.sp-body');
let currentSpTab = null; // 'data' | 'inventory' | 'build' | 'tech'
const SP_BODY = { data:'spDataBody', inventory:'spInvBody', build:'spBuildBody', tech:'spTechBody' };
function openSidePanel(tab) {
sidePanel.classList.add('open');
document.body.classList.add('side-open');
// switch tab
spTabs.forEach(t => t.classList.toggle('active', t.dataset.sp === tab));
spBodies.forEach(b => b.classList.toggle('active', b.id === SP_BODY[tab]));
// button states
btnData.classList.toggle('active', tab === 'data');
btnInventory.classList.toggle('active', tab === 'inventory');
btnBuild.classList.toggle('active', tab === 'build');
btnTech.classList.toggle('active', tab === 'tech');
currentSpTab = tab;
// render content on first open
if (tab === 'data') renderData();
if (tab === 'inventory') renderInventory();
if (tab === 'build') { buildMode = true; document.getElementById('buildPanel').classList.add('active'); }
if (tab === 'tech') renderTech();
}
function closeSidePanel() {
sidePanel.classList.remove('open');
document.body.classList.remove('side-open');
btnData.classList.remove('active'); btnInventory.classList.remove('active'); btnBuild.classList.remove('active'); btnTech.classList.remove('active');
// exit build mode if was active
if (currentSpTab === 'build') {
buildMode = false; toggle.classList.remove('active');
document.getElementById('buildPanel').classList.remove('active');
swapFirst = null; draggingSwap = false; dragSwapFrom = null;
}
currentSpTab = null;
}
btnData.addEventListener('click', () => {
if (currentSpTab === 'data') { closeSidePanel(); return; }
openSidePanel('data');
});
btnInventory.addEventListener('click', () => {
if (currentSpTab === 'inventory') { closeSidePanel(); return; }
openSidePanel('inventory');
});
btnBuild.addEventListener('click', () => {
if (currentSpTab === 'build') { closeSidePanel(); return; }
openSidePanel('build');
});
btnTech.addEventListener('click', () => {
if (currentSpTab === 'tech') { closeSidePanel(); return; }
openSidePanel('tech');
});
// tab clicks inside side panel
spTabs.forEach(t => t.addEventListener('click', () => openSidePanel(t.dataset.sp)));
// buildPanel close → also close side panel
bpClose.onclick = () => {
buildMode = false; toggle.classList.remove('active');
document.getElementById('buildPanel').classList.remove('active');
swapFirst = null; draggingSwap = false; dragSwapFrom = null;
closeSidePanel();
};
// ===== 世界数据面板(演示 + 预留 /yt_world/api/data =====
function computeWorldStats() {
const byTerrain = {};
let sumH = 0, n = 0, minH = Infinity, maxH = -Infinity;
for (const t of tiles) {
if (t.layer !== 'earth') continue;
byTerrain[t.terrain] = (byTerrain[t.terrain] || 0) + 1;
sumH += t.height; n++;
if (t.height < minH) minH = t.height;
if (t.height > maxH) maxH = t.height;
}
return { total: n, byTerrain, avgH: n ? sumH / n : 0, minH: n ? minH : 0, maxH: n ? maxH : 0 };
}
async function renderData() {
const body = document.getElementById('spDataBody');
const s = computeWorldStats();
let remote = null;
try { remote = await API.worldData(); } catch (e) {}
const usingRemote = !!(remote && remote.stats);
const terr = s.byTerrain;
const TICON = { water:'🔵', desert:'🟡', plain:'🟢', forest:'🌲', mountain:'🟤', snow:'❄' };
const terrRows = Object.keys(TERRAIN).map(id => {
const c = terr[id] || 0;
const pct = s.total ? Math.round(c / s.total * 100) : 0;
return `<div class="dp-row"><span>${TICON[id] || ''} ${TERRAIN[id].name}</span><span>${c} 块 (${pct}%)</span></div>`;
}).join('');
body.innerHTML = `
<div class="dp-section">
<div class="dp-h">概览</div>
<div class="dp-row"><span>地块总数</span><span>${s.total}</span></div>
<div class="dp-row"><span>平均高度</span><span>${s.avgH.toFixed(1)}</span></div>
<div class="dp-row"><span>高度区间</span><span>${s.minH} ${s.maxH}</span></div>
</div>
<div class="dp-section">
<div class="dp-h">地形分布</div>
${terrRows}
</div>
<div class="dp-note">${usingRemote ? '已连接后端世界数据接口(/yt_world/api/data' : '演示数据:前端实时从地块计算。后端接口 <code>/yt_world/api/data</code> 待接入。'}</div>
`;
}
// ===== 包裹面板7列 × 行格 + 物资分类) =====
const INV_CATEGORIES = {
brush: [
{ ico:'🟢', nm:'平原', ct:'∞' }, { ico:'🔵', nm:'水域', ct:'∞' }, { ico:'🟤', nm:'山地', ct:'∞' },
{ ico:'🌲', nm:'森林', ct:'∞' }, { ico:'🟡', nm:'荒漠', ct:'∞' }, { ico:'❄', nm:'雪原', ct:'∞' },
{ ico:'⬜', nm:'空地', ct:'∞' },
],
template: [
{ ico:'🏠', nm:'村落', ct:3 }, { ico:'🏰', nm:'城寨', ct:2 }, { ico:'⛪', nm:'神殿', ct:1 },
{ ico:'🌾', nm:'农田', ct:5 }, { ico:'🪨', nm:'矿场', ct:4 }, { ico:'🛤', nm:'道路', ct:8 },
{ ico:'🌉', nm:'桥梁', ct:3 }, { ico:'🗼', nm:'高塔', ct:1 }, { ico:'⚱', nm:'雕像', ct:2 },
{ ico:'🏕', nm:'营地', ct:4 }, { ico:'🔭', nm:'观星台', ct:1 }, { ico:'📯', nm:'烽火', ct:6 },
{ ico:'💧', nm:'水井', ct:5 }, { ico:'🌳', nm:'灵树', ct:2 }, { ico:'🪨', nm:'石碑', ct:3 },
],
event: [
{ ico:'🌟', nm:'丰收', ct:2 }, { ico:'⚡', nm:'雷暴', ct:1 }, { ico:'❄️', nm:'冰封', ct:1 },
{ ico:'🌋', nm:'地动', ct:1 }, { ico:'🌈', nm:'虹息', ct:2 }, { ico:'💫', nm:'陨落', ct:1 },
{ ico:'🌀', nm:'风暴', ct:1 }, { ico:'☀️', nm:'大旱', ct:1 }, { ico:'🌙', nm:'月蚀', ct:1 },
{ ico:'✨', nm:'灵潮', ct:3 }, { ico:'🔥', nm:'野火', ct:1 }, { ico:'💎', nm:'矿脉', ct:2 },
{ ico:'🎋', nm:'竹生', ct:4 }, { ico:'🍂', nm:'落叶', ct:3 }, { ico:'🐦', nm:'迁徙', ct:2 },
],
};
function renderInvGrid(containerId, items) {
const el = document.getElementById(containerId);
// pad to multiple of 7 for clean grid
const padded = [...items];
while (padded.length % 7 !== 0) padded.push(null);
el.innerHTML = padded.map(it => {
if (!it) return '<div class="inv-slot"></div>';
const has = it.ct !== '∞';
return `<div class="inv-slot${has ? ' has-item' : ''}" title="${it.nm}${has ? ' ×'+it.ct : ' 无限'}">
${it.ico}<span class="inv-ct">${has ? it.ct : ''}</span></div>`;
}).join('');
}
async function renderInventory() {
let remote = null;
try { remote = await API.inventory(); } catch (e) {}
if (remote && Array.isArray(remote.items)) {
// TODO: 后端返回带 category 字段时按分类渲染
renderInvGrid('invGridBrush', remote.items.filter(i => i.cat === 'brush'));
renderInvGrid('invGridTemplate', remote.items.filter(i => i.cat === 'template'));
renderInvGrid('invGridEvent', remote.items.filter(i => i.cat === 'event'));
} else {
renderInvGrid('invGridBrush', INV_CATEGORIES.brush);
renderInvGrid('invGridTemplate', INV_CATEGORIES.template);
renderInvGrid('invGridEvent', INV_CATEGORIES.event);
}
}
// ===== 科技树(演示 + 预留 /yt_world/api/tech =====
// 节点按 tier 分阶:前置满足且研究点充足才可研习;状态存 localStorage预留后端 /yt_world/api/tech 同步。
const TECH_TREE = [
{ id:'gather', tier:0, ico:'🌿', name:'采集术', cost:1, pre:[], desc:'高效采集资源,建造笔刷消耗 10%。' },
{ id:'fire', tier:0, ico:'🔥', name:'火耕', cost:1, pre:[], desc:'点燃荒原,解锁烧荒造地与基础冶炼。' },
{ id:'farm', tier:1, ico:'🌾', name:'农艺', cost:2, pre:['gather'], desc:'开垦农田产量 +30%,解锁轮作。' },
{ id:'path', tier:1, ico:'🛤', name:'道途', cost:2, pre:['gather'], desc:'道路连通使单位移动更快。' },
{ id:'masonry', tier:1, ico:'🧱', name:'砌筑', cost:2, pre:['fire'], desc:'解锁石质建筑(城寨 / 神殿)。' },
{ id:'irrig', tier:2, ico:'💧', name:'水利', cost:3, pre:['farm'], desc:'灌溉网络,干旱事件减伤。' },
{ id:'market', tier:2, ico:'🏪', name:'集市', cost:3, pre:['farm','path'], desc:'贸易节点,灵气积累加速。' },
{ id:'fort', tier:2, ico:'🏰', name:'城防', cost:3, pre:['masonry'], desc:'城墙与守卫,抵御地动 / 风暴。' },
{ id:'spirit', tier:3, ico:'🔮', name:'灵术', cost:4, pre:['irrig','market'], desc:'操控灵气,解锁灵树速生。' },
{ id:'astral', tier:3, ico:'🔭', name:'星图', cost:4, pre:['fort'], desc:'观星推演,预测事件与灾异。' },
{ id:'ascend', tier:4, ico:'🌟', name:'升灵', cost:5, pre:['spirit','astral'], desc:'世界飞升,解锁星空层建造。' },
];
const TECH_TIER_NAMES = { 0:'启明', 1:'匠作', 2:'兴业', 3:'通玄', 4:'飞升' };
const TECH_POINTS_START = 6; // [PLACEHOLDER] 演示起始研究点,待后端 / 平衡校准
const TECH_SAVE_KEY = 'world_tech_save';
let techState = loadTechState();
function loadTechState() {
try {
const raw = localStorage.getItem(TECH_SAVE_KEY);
if (raw) {
const o = JSON.parse(raw);
return { done: new Set(o.done || []), points: (o.points != null ? o.points : TECH_POINTS_START) };
}
} catch (e) {}
return { done: new Set(), points: TECH_POINTS_START };
}
function saveTechState() {
try { localStorage.setItem(TECH_SAVE_KEY, JSON.stringify({ done:[...techState.done], points: techState.points })); } catch (e) {}
}
function techPreMet(t) { return t.pre.every(p => techState.done.has(p)); }
function renderTech() {
const body = document.getElementById('spTechBody');
const byTier = {};
for (const t of TECH_TREE) (byTier[t.tier] = byTier[t.tier] || []).push(t);
const tiers = Object.keys(byTier).sort((a,b)=>a-b).map(tier => {
const nodes = byTier[tier].map(t => {
const done = techState.done.has(t.id);
const met = techPreMet(t);
const afford = techState.points >= t.cost;
const cls = ['tech-node'];
if (done) cls.push('done'); else if (!met) cls.push('locked'); else if (afford) cls.push('afford');
const preHtml = t.pre.length
? '前置:' + t.pre.map(p => {
const d = TECH_TREE.find(x => x.id === p);
const ok = techState.done.has(p);
return `<b class="${ok ? 'met' : ''}">${d ? d.name : p}</b>`;
}).join('、')
: '前置:无(启明科技)';
let btn;
if (done) btn = `<button class="tn-btn" disabled>✓ 已研习</button>`;
else if (!met) btn = `<button class="tn-btn" disabled>未解锁</button>`;
else btn = `<button class="tn-btn" data-tech="${t.id}">研究(${t.cost} 点)</button>`;
return `<div class="${cls.join(' ')}">
<div class="tn-top"><span class="tn-ico">${t.ico}</span><span class="tn-name">${t.name}</span></div>
<div class="tn-desc">${t.desc}</div>
<div class="tn-pre">${preHtml}</div>
<div class="tn-cost">花费 ${t.cost} 研究点</div>
${btn}
</div>`;
}).join('');
return `<div class="tech-tier"><div class="tech-tier-h">${TECH_TIER_NAMES[tier] || ('阶'+tier)} · 第 ${+tier+1} 阶</div><div class="tech-nodes">${nodes}</div></div>`;
}).join('');
body.innerHTML = `
<div class="tech-pool">
<span class="tp-label">研究点</span>
<span class="tp-val">${techState.points}</span>
<button class="tp-reset" id="techReset">重置</button>
</div>
${tiers}
<div class="tech-note">演示科技树:前置满足且研究点充足即可研习,状态存本地。后端接口 <code>/yt_world/api/tech</code> 待接入,将支持跨端同步与真正的增益结算。</div>
`;
body.querySelectorAll('.tn-btn[data-tech]').forEach(b => {
b.addEventListener('click', () => {
const id = b.dataset.tech;
const t = TECH_TREE.find(x => x.id === id);
if (!t || techState.done.has(id) || !techPreMet(t) || techState.points < t.cost) return;
techState.points -= t.cost;
techState.done.add(id);
saveTechState();
renderTech();
});
});
const reset = body.querySelector('#techReset');
if (reset) reset.onclick = () => {
if (!confirm('重置科技树?已研习进度与研究点将清空。')) return;
techState = { done: new Set(), points: TECH_POINTS_START };
saveTechState();
renderTech();
};
}
// 平铺把所有地块高度设为最低1一键压平沿用 setHeight 派生 terrain/tier/variant // 平铺把所有地块高度设为最低1一键压平沿用 setHeight 派生 terrain/tier/variant
const bpFlatten = document.getElementById('bpFlatten'); const bpFlatten = document.getElementById('bpFlatten');