Archived
412 lines
11 KiB
HTML
412 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>数学少年线 · 互动剧本</title>
|
|
<style>
|
|
:root{
|
|
--ink:#182430;
|
|
--ink-2:#263947;
|
|
--paper:#F7F3EA;
|
|
--paper-2:#FFFDF8;
|
|
--teal:#2E7D72;
|
|
--gold:#B8893A;
|
|
--blue:#3F6C8F;
|
|
--red:#A64A44;
|
|
--muted:#7C8790;
|
|
--line:#D8D2C4;
|
|
--shadow:0 18px 50px rgba(24,36,48,.16);
|
|
}
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
body{
|
|
min-height:100vh;
|
|
background:
|
|
radial-gradient(circle at 15% 10%, rgba(62,108,143,.16), transparent 32%),
|
|
radial-gradient(circle at 85% 20%, rgba(46,125,114,.14), transparent 30%),
|
|
linear-gradient(160deg,#E9E4D8 0%,#F4F0E7 45%,#DDE5E4 100%);
|
|
color:var(--ink);
|
|
font-family:"PingFang SC","Microsoft YaHei","Segoe UI",sans-serif;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
padding:22px;
|
|
}
|
|
.shell{
|
|
width:100%;
|
|
max-width:860px;
|
|
background:var(--paper-2);
|
|
border:1px solid var(--line);
|
|
border-radius:18px;
|
|
box-shadow:var(--shadow);
|
|
overflow:hidden;
|
|
}
|
|
.topbar{
|
|
display:flex;
|
|
align-items:center;
|
|
gap:14px;
|
|
padding:18px 22px;
|
|
background:linear-gradient(120deg,var(--ink) 0%,var(--ink-2) 100%);
|
|
color:#F7F3EA;
|
|
}
|
|
.mark{
|
|
width:46px;
|
|
height:46px;
|
|
border-radius:50% 50% 50% 50% / 60% 60% 40% 40%;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
font-size:24px;
|
|
font-weight:700;
|
|
background:linear-gradient(140deg,var(--teal),var(--blue));
|
|
color:#fff;
|
|
box-shadow:0 8px 20px rgba(0,0,0,.22);
|
|
flex:none;
|
|
}
|
|
.topbar h1{font-size:21px;font-weight:700;letter-spacing:.5px}
|
|
.topbar p{font-size:12px;color:#C9D4DA;margin-top:4px;line-height:1.5}
|
|
.top-actions{margin-left:auto;display:flex;gap:8px}
|
|
.top-actions button{
|
|
border:1px solid rgba(255,255,255,.24);
|
|
background:rgba(255,255,255,.08);
|
|
color:#F7F3EA;
|
|
padding:8px 13px;
|
|
border-radius:10px;
|
|
font-size:12px;
|
|
cursor:pointer;
|
|
transition:.2s;
|
|
}
|
|
.top-actions button:hover{background:rgba(255,255,255,.18)}
|
|
.stats{
|
|
display:flex;
|
|
gap:10px;
|
|
padding:14px 22px;
|
|
border-bottom:1px solid var(--line);
|
|
background:var(--paper);
|
|
flex-wrap:wrap;
|
|
}
|
|
.stat{
|
|
flex:1 1 130px;
|
|
min-width:130px;
|
|
border:1px solid var(--line);
|
|
border-radius:12px;
|
|
padding:10px 12px;
|
|
background:#fff;
|
|
display:flex;
|
|
align-items:center;
|
|
gap:9px;
|
|
}
|
|
.stat-ico{
|
|
width:30px;
|
|
height:30px;
|
|
border-radius:9px;
|
|
display:flex;
|
|
align-items:center;
|
|
justify-content:center;
|
|
font-size:14px;
|
|
font-weight:700;
|
|
color:#fff;
|
|
flex:none;
|
|
}
|
|
.stat b{display:block;font-size:15px;line-height:1.2}
|
|
.stat span{font-size:11px;color:var(--muted)}
|
|
.stage{
|
|
padding:22px 22px 0;
|
|
display:flex;
|
|
align-items:center;
|
|
gap:12px;
|
|
font-size:12px;
|
|
color:var(--muted);
|
|
}
|
|
.stage-line{height:1px;flex:1;background:var(--line)}
|
|
.stage-tag{
|
|
color:var(--teal);
|
|
font-weight:700;
|
|
border:1px solid rgba(46,125,114,.35);
|
|
background:rgba(46,125,114,.08);
|
|
padding:4px 10px;
|
|
border-radius:999px;
|
|
letter-spacing:1px;
|
|
}
|
|
.reader{
|
|
padding:18px 24px 26px;
|
|
min-height:300px;
|
|
}
|
|
.speaker{
|
|
display:inline-flex;
|
|
align-items:center;
|
|
gap:8px;
|
|
margin-bottom:14px;
|
|
color:var(--ink-2);
|
|
font-size:13px;
|
|
font-weight:700;
|
|
}
|
|
.speaker::before{
|
|
content:"";
|
|
width:8px;
|
|
height:8px;
|
|
border-radius:50%;
|
|
background:var(--gold);
|
|
}
|
|
.scene-text{
|
|
font-size:17px;
|
|
line-height:1.95;
|
|
white-space:pre-wrap;
|
|
color:var(--ink);
|
|
min-height:180px;
|
|
}
|
|
.choices{
|
|
display:flex;
|
|
flex-direction:column;
|
|
gap:10px;
|
|
padding:0 24px 26px;
|
|
}
|
|
.choice{
|
|
display:block;
|
|
width:100%;
|
|
text-align:left;
|
|
border:1px solid var(--line);
|
|
border-left:4px solid var(--teal);
|
|
background:#fff;
|
|
color:var(--ink);
|
|
border-radius:12px;
|
|
padding:14px 16px;
|
|
font-size:15px;
|
|
line-height:1.5;
|
|
cursor:pointer;
|
|
transition:.18s;
|
|
}
|
|
.choice:hover{
|
|
transform:translateY(-2px);
|
|
border-color:var(--teal);
|
|
box-shadow:0 8px 20px rgba(46,125,114,.14);
|
|
}
|
|
.choice small{
|
|
display:block;
|
|
margin-top:5px;
|
|
color:var(--muted);
|
|
font-size:11px;
|
|
}
|
|
.end-note{
|
|
text-align:center;
|
|
padding:10px 0 4px;
|
|
color:var(--muted);
|
|
font-size:13px;
|
|
}
|
|
.footer{
|
|
padding:12px 22px;
|
|
border-top:1px solid var(--line);
|
|
background:var(--paper);
|
|
color:var(--muted);
|
|
font-size:11px;
|
|
text-align:center;
|
|
}
|
|
@media (max-width:640px){
|
|
body{padding:12px}
|
|
.shell{border-radius:14px}
|
|
.topbar{padding:14px 16px}
|
|
.stats{padding:12px 14px}
|
|
.stat{flex-basis:calc(50% - 6px);min-width:0}
|
|
.reader{padding:16px 16px 20px}
|
|
.choices{padding:0 16px 20px}
|
|
.scene-text{font-size:15px}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="shell">
|
|
<div class="topbar">
|
|
<div class="mark">葫</div>
|
|
<div>
|
|
<h1>数学少年线</h1>
|
|
<p>八幕完整版 · 大学数学专业四年 · 从录取通知书到直博</p>
|
|
</div>
|
|
<div class="top-actions">
|
|
<button onclick="resetGame()">重新开始</button>
|
|
</div>
|
|
</div>
|
|
<div class="stats" id="statsBar"></div>
|
|
<div class="stage" id="stageBar"></div>
|
|
<div class="reader">
|
|
<div class="speaker" id="speaker">旁白</div>
|
|
<div class="scene-text" id="sceneText">正在加载剧本……</div>
|
|
</div>
|
|
<div class="choices" id="choicesArea"></div>
|
|
<div class="footer">数学少年线 · 完整互动剧本 v2 · 85 节点 / 107 选项</div>
|
|
</div>
|
|
|
|
<script>
|
|
const SAVE_KEY = 'math_teen_story_save';
|
|
let storyData = null;
|
|
let state = {
|
|
currentNode: null,
|
|
stats: { shuli: 10, xiayi: 10, xinzhi: 10 },
|
|
fav: {},
|
|
history: []
|
|
};
|
|
let typeTimer = null;
|
|
|
|
function esc(s){
|
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
}
|
|
|
|
function stageOf(id){
|
|
const map = {
|
|
c1: '第一幕 · 大一上:小镇少年走进数学系',
|
|
c2: '第二幕 · 大一下:第一次完整证明',
|
|
c3: '第三幕 · 大二上:讨论班与诺特',
|
|
c4: '第四幕 · 大二下:数学与生活',
|
|
c5: '第五幕 · 大三上:方向与高斯',
|
|
c6: '第六幕 · 大三下:科研低谷与诺特',
|
|
c7: '第七幕 · 大四上:直博申请',
|
|
c8: '第八幕 · 毕业前夜与直博第一年'
|
|
};
|
|
const key = (id || '').slice(0,2);
|
|
return map[key] || '数学少年线';
|
|
}
|
|
|
|
function statColor(k){
|
|
if(k==='shuli') return '#3F6C8F';
|
|
if(k==='xinzhi') return '#2E7D72';
|
|
return '#B8893A';
|
|
}
|
|
|
|
function statLabel(k){
|
|
if(k==='shuli') return '数理根基';
|
|
if(k==='xinzhi') return '心志韧性';
|
|
return '同行温度';
|
|
}
|
|
|
|
function statIcon(k){
|
|
if(k==='shuli') return '∑';
|
|
if(k==='xinzhi') return 'θ';
|
|
return 'π';
|
|
}
|
|
|
|
function renderStats(){
|
|
const keys = ['shuli','xiayi','xinzhi'];
|
|
document.getElementById('statsBar').innerHTML = keys.map(k => {
|
|
const v = state.stats[k] || 0;
|
|
return '<div class="stat"><div class="stat-ico" style="background:'+statColor(k)+'">'+statIcon(k)+'</div><div><b>'+v+'</b><span>'+statLabel(k)+'</span></div></div>';
|
|
}).join('') + '<div class="stat" style="flex-basis:120px"><div class="stat-ico" style="background:#A64A44">4</div><div><b>4 位数学家</b><span>高斯 · 祖冲之 · 欧拉 · 诺特</span></div></div>';
|
|
}
|
|
|
|
function renderStage(){
|
|
document.getElementById('stageBar').innerHTML = '<span class="stage-tag">'+esc(stageOf(state.currentNode))+'</span><span class="stage-line"></span><span>'+esc(storyData.title)+'</span>';
|
|
}
|
|
|
|
function renderNode(){
|
|
if(!storyData || !storyData.nodes) return;
|
|
const node = storyData.nodes[state.currentNode];
|
|
if(!node){
|
|
document.getElementById('sceneText').textContent = '节点未找到,请重新开始。';
|
|
return;
|
|
}
|
|
if(typeTimer) clearTimeout(typeTimer);
|
|
renderStats();
|
|
renderStage();
|
|
document.getElementById('speaker').textContent = node.character || '旁白';
|
|
const textEl = document.getElementById('sceneText');
|
|
const full = node.scene || '';
|
|
textEl.textContent = '';
|
|
let i = 0;
|
|
function type(){
|
|
if(i < full.length){
|
|
textEl.textContent += full.charAt(i);
|
|
i++;
|
|
typeTimer = setTimeout(type, 16);
|
|
} else {
|
|
renderChoices(node.choices || []);
|
|
}
|
|
}
|
|
type();
|
|
saveState();
|
|
}
|
|
|
|
function renderChoices(choices){
|
|
const area = document.getElementById('choicesArea');
|
|
if(!choices.length){
|
|
area.innerHTML = '<div class="end-note">故事结束 · 你的数学,刚刚开始</div><button class="choice" onclick="resetGame()">再走一次这段路</button>';
|
|
return;
|
|
}
|
|
area.innerHTML = choices.map((c, idx) => {
|
|
const effect = c.effects || {};
|
|
const parts = [];
|
|
if(effect.shuli) parts.push('数理根基 '+(effect.shuli>0?'+':'')+effect.shuli);
|
|
if(effect.xinzhi) parts.push('心志韧性 '+(effect.xinzhi>0?'+':'')+effect.xinzhi);
|
|
if(effect.xiayi) parts.push('同行温度 '+(effect.xiayi>0?'+':'')+effect.xiayi);
|
|
const hint = parts.length ? '<small>'+parts.join(' · ')+'</small>' : '';
|
|
return '<button class="choice" onclick="choose(\''+esc(c.next)+'\', '+idx+')">'+esc(c.text)+hint+'</button>';
|
|
}).join('');
|
|
}
|
|
|
|
function choose(next, idx){
|
|
if(typeTimer) clearTimeout(typeTimer);
|
|
const node = storyData.nodes[state.currentNode];
|
|
const choice = (node.choices || [])[idx];
|
|
if(!choice) return;
|
|
const effects = choice.effects || {};
|
|
Object.keys(effects).forEach(k => {
|
|
if(k === 'favorability'){
|
|
Object.keys(effects[k] || {}).forEach(name => {
|
|
state.fav[name] = (state.fav[name] || 0) + effects[k][name];
|
|
});
|
|
} else {
|
|
state.stats[k] = (state.stats[k] || 0) + effects[k];
|
|
}
|
|
});
|
|
state.history.push(state.currentNode);
|
|
state.currentNode = next;
|
|
renderNode();
|
|
}
|
|
|
|
function saveState(){
|
|
try { localStorage.setItem(SAVE_KEY, JSON.stringify(state)); } catch(e){}
|
|
}
|
|
|
|
function loadState(){
|
|
try {
|
|
const raw = localStorage.getItem(SAVE_KEY);
|
|
if(raw){
|
|
const saved = JSON.parse(raw);
|
|
if(saved && saved.currentNode && storyData && storyData.nodes[saved.currentNode]){
|
|
state = Object.assign(state, saved);
|
|
return true;
|
|
}
|
|
}
|
|
} catch(e){}
|
|
return false;
|
|
}
|
|
|
|
function resetGame(){
|
|
if(typeTimer) clearTimeout(typeTimer);
|
|
state = {
|
|
currentNode: storyData ? storyData.start_node : null,
|
|
stats: { shuli: 10, xiayi: 10, xinzhi: 10 },
|
|
fav: {},
|
|
history: []
|
|
};
|
|
try { localStorage.removeItem(SAVE_KEY); } catch(e){}
|
|
if(storyData) renderNode();
|
|
}
|
|
|
|
async function boot(){
|
|
try {
|
|
const resp = await fetch('/api/story/math_teen');
|
|
const json = await resp.json();
|
|
if(json.code !== 0) throw new Error(json.msg || '加载失败');
|
|
storyData = json.data;
|
|
if(!loadState()){
|
|
state.currentNode = storyData.start_node;
|
|
}
|
|
renderNode();
|
|
} catch(e){
|
|
document.getElementById('sceneText').textContent = '剧本加载失败,请先启动 Flask 服务后访问本页。';
|
|
}
|
|
}
|
|
|
|
boot();
|
|
</script>
|
|
</body>
|
|
</html>
|