This repository has been archived on 2026-08-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Hulumath-Web-Demo/templates/math_teen.html
T
2026-08-08 15:49:06 +08:00

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
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>