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 @@ - -
+ +
+
+ +
+
从左侧目录选择章节开始阅读
+
+
+