diff --git a/addons/game_base/controllers/game_api.py b/addons/game_base/controllers/game_api.py
index debd622..f57e62f 100644
--- a/addons/game_base/controllers/game_api.py
+++ b/addons/game_base/controllers/game_api.py
@@ -172,6 +172,49 @@ class GameApiController(http.Controller):
} for r in recs]
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/', 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 打通) ============
@http.route('/game/api/login', type='http', auth='public',
diff --git a/addons/game_base/data/game_base_data.xml b/addons/game_base/data/game_base_data.xml
index 9e589ad..5c74301 100644
--- a/addons/game_base/data/game_base_data.xml
+++ b/addons/game_base/data/game_base_data.xml
@@ -249,4 +249,112 @@
欢迎播种。
]]>
+
+
+ 【公告】宇森官网第一阶段上线,欢迎园丁们入驻
+ discuss
+ 官方
+ 1
+ 宇森官网第一阶段上线:介绍 / 规则 / 图鉴 / 论坛 / 公告 / 剧情 六块,单页切换呈现。欢迎园丁们入驻,一起把这个世界养活。
]]>
+
+
+ 新手向:园丁视角的三种开局思路
+ strategy
+ 行者
+ ① 静观其变:前期少干预,先把灵气浓度调匀;② 重点扶持:选一个族群定向加灵气,加速聚落成型;③ 制造意外:故意制造资源稀缺,观察文明如何应对。你更偏向哪种?]]>
+
+
+ 世界观考据:五层世界是怎么来的
+ lore
+ 雾岛
+ 从地心熔岩到陨石星空,五层世界是灵气自下而上攀爬的切面。凡墟→灵启→玄界→穹冥→溯元,每一层都有独立的生态与事件池,文明则是被这五层同时托举的。]]>
+
+
+ 手绘了我的主世界浮空岛,求轻喷
+ showcase
+ 青柠
+ 肝了三个晚上,把我主世界那座浮空岛画出来了,云朵地基块 + 中央灵泉,欢迎来捞图也欢迎拍砖。]]>
+
+
+ 反馈:飞入微观时偶现卡顿
+ bug
+ K
+ 进入微观沙盘的前一两秒偶尔会掉帧,退出再进就正常了。设备是集显笔记本,疑似首帧资源加载没预载,供开发组参考。]]>
+
+
+
+
+ 【前瞻】微观沙盘与剧情站即将开放
+ 2026-07-12
+ important
+ 下一阶段将开放「微观沙盘」与「剧情站」。前者让你亲手摆放六边形地块、引导文明生长;后者将连载《园丁手记》系列小说,记录这个世界的来龙去脉。]]>
+
+
+ 【须知】社区公约与发帖规范
+ 2026-07-15
+ normal
+ 论坛仅对登录用户开放。请友善交流、尊重原创;同人创作欢迎标注「二创」,攻略与考据可自由转载,转载请注明出处。]]>
+
+
+ 【公告】剧情站上线:《园丁手记·凡墟篇》
+ 2026-07-20
+ important
+ 剧情站正式上线!首卷《园丁手记·凡墟篇》已开放阅读,记录灵气初醒、第一株苗与聚落第一缕光的蒙昧年代。前往导航「剧情」即可翻阅。]]>
+
+
+
+
+ 第一卷 · 园丁手记·凡墟篇
+ 10
+ 凡墟篇·园丁手记。灵气初醒的蒙昧年代,文明的种子刚刚落进泥土。]]>
+
+
+
+ 10
+ 楔子 · 灵气初醒
+ 天地初开的时候,没有名字。
+只有一团混沌的灵气,从地心最深处艰难地往上爬——它穿过滚烫的岩浆,穿过沉默的岩层,在某个谁也说不清的时刻,第一次触到了冰冷的地表。
+那一刻,凡土醒了。
+后来的人把它叫做「灵气初醒」。但在当时,没有谁记得这一刻。只有风,把第一缕微弱的生机,吹向了荒野里零散的族群。
]]>
+
+
+
+ 20
+ 第一章 · 第一株苗
+ 园丁第一次伸手,是在一片荒芜的斜坡上。
+他什么也没做,只是把掌心的灵气,轻轻按进了一粒埋在石缝里的种子。没有咒语,没有仪式——只是给予,然后等待。
+三天后,一株嫩绿的苗破土而出。它不知道自己被谁照料,也不在乎。它只是生长,像这个世界本该有的样子。
+园丁看着那点绿色,忽然明白了自己该做的事:不是统治,不是创造,而是——让生长发生。
]]>
+
+
+
+ 30
+ 第二章 · 聚落的第一缕光
+ 苗活下来之后,荒野里有了第二个、第三个生命。
+零散的族群循着灵气的痕迹聚拢。他们不再只是狩猎采集,开始用碎石垒出简陋的屋舍,用兽骨交换 surplus 的猎物。第一缕属于「文明」的光,在凡墟的夜色里亮起。
+园丁没有现身。他只是把灵气的浓度,往聚落的方向,悄悄调浓了半分。
]]>
+
+
+
+ 第二卷 · 园丁手记·灵启篇
+ 20
+ 灵启篇·园丁手记。当聚落连成部族,争与和同时降临。]]>
+
+
+
+ 10
+ 第一章 · 部族初成
+ 灵气浓到一定程度,聚落开始兼并。
+三个相邻的族群,在一场没有流血的议事之后,决定共用一口深井。这是凡墟历史上第一次「联盟」——没有王,只有约定。
+园丁在云端记下了这一笔。他知道,从今往后,世界不再只是一群野生的人,而是一个会自己谈判、自己妥协的活物。
]]>
+
+
+
+ 20
+ 第二章 · 交易与争端
+ 联盟带来了繁荣,也带来了第一场争端。
+东边的墟族说井水该归他们,西边的人不肯。争执了七天,最后是园丁在不经意间,让井边多长出一片可食用的苔藓——谁都能采,谁都不必独占。
+争端平息的方式,从来不在胜负,而在有没有第三种选择。这是园丁学到的第二课。
]]>
+
+
diff --git a/addons/game_base/models/__init__.py b/addons/game_base/models/__init__.py
index ebfc125..5ecebe0 100644
--- a/addons/game_base/models/__init__.py
+++ b/addons/game_base/models/__init__.py
@@ -4,3 +4,4 @@ from . import announcement
from . import rule
from . import currency
from . import page
+from . import story
diff --git a/addons/game_base/models/story.py b/addons/game_base/models/story.py
new file mode 100644
index 0000000..9e35231
--- /dev/null
+++ b/addons/game_base/models/story.py
@@ -0,0 +1,27 @@
+# -*- 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='正文')
diff --git a/addons/game_base/security/ir.model.access.csv b/addons/game_base/security/ir.model.access.csv
index b8bb0c4..d588c72 100644
--- a/addons/game_base/security/ir.model.access.csv
+++ b/addons/game_base/security/ir.model.access.csv
@@ -15,3 +15,7 @@ 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_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_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
diff --git a/addons/game_base/views/game_base_views.xml b/addons/game_base/views/game_base_views.xml
index bbb79ff..e9c4add 100644
--- a/addons/game_base/views/game_base_views.xml
+++ b/addons/game_base/views/game_base_views.xml
@@ -203,4 +203,59 @@
+
+
+
+ 卷
+ game.story.volume
+ list,form
+
+
+
+ 章节
+ game.story.chapter
+ list,form
+
+
+
+
+ game.story.volume.list
+ game.story.volume
+
+
+
+
+
+ game.story.volume.form
+ game.story.volume
+
+
+
+
+
+ game.story.chapter.list
+ game.story.chapter
+
+
+
+
+
+ game.story.chapter.form
+ game.story.chapter
+
+
+
+
+
diff --git a/assets/game-api.js b/assets/game-api.js
index 3a00622..ecae512 100644
--- a/assets/game-api.js
+++ b/assets/game-api.js
@@ -60,6 +60,12 @@ const API = {
async announcements() {
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 模块) ----------
async world() {
return (await fetch('/yt_world/api/world?db=yt_game', { credentials: 'same-origin' })).json();
@@ -78,4 +84,19 @@ const API = {
body: JSON.stringify({ settings }),
})).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; }
+ },
};
diff --git a/assets/site-app.js b/assets/site-app.js
index 52f5de4..bbdd67a 100644
--- a/assets/site-app.js
+++ b/assets/site-app.js
@@ -32,9 +32,6 @@ async function loadPages(){
else if (p.key === 'letter') html += letterHTML(p);
});
if (bg) bg.innerHTML = html; // 特色 + 背景 + 致玩家 平铺进同一页,无子菜单切换
-
- const story = document.getElementById('story');
- if (story) story.innerHTML = '剧情STORY
';
} catch (e) { /* 失败时保留 HTML 中的静态兜底内容 */ }
}
@@ -99,9 +96,51 @@ async function loadAnnounce(){
} 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 = '目录加载失败
'; return; }
+ const volumes = (data && data.volumes) || [];
+ if (!volumes.length){ tocList.innerHTML = '暂无内容
'; 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 '';
+ }).join('');
+ return '' + v.name + '
' +
+ (v.intro ? '
' + v.intro + '
' : '') + chs + '
';
+ }).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 = '加载中…
';
+ try {
+ const d = await API.storyChapter(cid);
+ if (!d || d.error){ reader.innerHTML = '' + ((d && d.error) || '章节加载失败') + '
'; return; }
+ reader.innerHTML =
+ '' + (volNameByCh[cid] || '') + '
' +
+ '
' + d.name + '
' + (d.body_html || '
(暂无正文)
') + '
';
+ reader.scrollTop = 0;
+ } catch (e) { reader.innerHTML = '章节加载失败
'; }
+ }
+
+ tocList.querySelectorAll('.toc-ch').forEach(b => b.addEventListener('click', () => openChapter(b.dataset.cid)));
+ if (firstCid !== null) openChapter(firstCid);
+}
+
document.addEventListener('DOMContentLoaded', () => {
loadPages();
loadRules();
loadForum();
loadAnnounce();
+ loadStory();
});
diff --git a/index.html b/index.html
index a396c51..b0ac6e8 100644
--- a/index.html
+++ b/index.html
@@ -186,10 +186,48 @@
#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;}
+ /* ===== 论坛(竖排:顶部分类条 + 下方纵向帖子流) ===== */
+ #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;}
+
@media (max-width:900px){
.grid{grid-template-columns:repeat(2,1fr);}
#background,#forum{flex-direction:column;overflow-y:auto;}
.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;}
.nav button{padding:8px 10px;font-size:13px;}
.nav{overflow-x:auto;scrollbar-width:none;}
@@ -206,7 +244,7 @@
-
+
@@ -361,8 +399,18 @@
-
-
+
+