Repository navigation
Expand file tree
/
Copy pathsrc.js
More file actions
58 lines (58 loc) · 19 KB
/
Copy pathsrc.js
File metadata and controls
58 lines (58 loc) · 19 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import {chapters} from './curriculum.js';
import {questionsFor} from './questions.js';
import {canEnterLevel, unlockedLevel} from './progression.js';
import worldUrl from './assets/world.png';
import iconUrl from './assets/app-icon.png';
import spritesUrl from './assets/sprites.png';
const atlases = import.meta.glob('./assets/area-*.png', {eager:true,query:'?url',import:'default'});
const total=chapters.length;
function scenery(index){return atlases[`./assets/area-${Math.floor(index/4)+1}.png`] || worldUrl;}
function sceneryPosition(index){return `${index%2?100:0}% ${Math.floor(index%4/2)?100:0}%`;}
const $=s=>document.querySelector(s);
let saved;try{saved=JSON.parse(localStorage.getItem('rune-quest-v2'));}catch{}
const state={done:Array.isArray(saved?.done)?[...new Set(saved.done.filter(n=>Number.isInteger(n)&&n>=0&&n<total))].sort((a,b)=>a-b):[],trials:Array.from({length:total},(_,i)=>Number.isInteger(saved?.trials?.[i])&&saved.trials[i]>=0&&saved.trials[i]<=5?saved.trials[i]:0),chapter:0,tab:'world',sound:false};
state.chapter=unlockedLevel(state.done,total);
state.done=state.done.filter(n=>n<state.chapter || (state.chapter===total-1 && state.done.includes(total-1)));
let player={x:48,y:72},keys=new Set(),modal=false,last=0,audio,activeTrial;
const hotspots=[{id:'wizard',x:35,y:64,label:'与巫师交谈'},{id:'trial',x:64,y:72,label:'阅读法术碑 · 开启试炼'},{id:'portal',x:80,y:76,label:'进入下一关'}];
function nearestHotspot(){const spots=state.chapter>=28?[...hotspots,{id:'dragon',x:84,y:56,label:'与诺克斯交互'}]:hotspots;return spots.map(s=>({...s,distance:Math.hypot(player.x-s.x,player.y-s.y)})).filter(s=>s.distance<12).sort((a,b)=>a.distance-b.distance)[0];}
function interactHotspot(){const h=nearestHotspot();if(!h){toast('靠近地图上的光点,微光亮起后按 E 交互。');return;}if(h.id==='wizard')dialogue();else if(h.id==='trial')challenge();else if(h.id==='dragon'){if(state.done.length===total)ending();else toast('诺克斯仍受诅咒束缚,完成知识试炼才能解救它。');}else if(!state.done.includes(state.chapter))toast('先完成本关试炼,才能点亮旅途之门。');else if(state.chapter===total-1)ending();else{state.chapter++;player={x:48,y:72};tone(520);render();}}
const escape=s=>String(s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
function save(){localStorage.setItem('rune-quest-v2',JSON.stringify({done:state.done,trials:state.trials}));}
function tone(freq=440){if(!state.sound)return;audio??=new AudioContext();const o=audio.createOscillator(),g=audio.createGain();o.type='triangle';o.frequency.value=freq;g.gain.setValueAtTime(.06,audio.currentTime);g.gain.exponentialRampToValueAtTime(.001,audio.currentTime+.25);o.connect(g);g.connect(audio.destination);o.start();o.stop(audio.currentTime+.25);}
function render(){state.chapter=Math.min(state.chapter,unlockedLevel(state.done,total));const c=chapters[state.chapter];$('#app').innerHTML=`
<header><a class="brand" href="#"><img class="brand-mark app-icon" src="${iconUrl}" alt="符文龙徽章"><span>PYTHON<span class="brand-sub">符 文 纪 事</span></span></a><nav aria-label="主导航">${[['world','冒险地图'],['book','法术手册'],['runes','符文收藏']].map(([id,name])=>`<button class="nav ${state.tab===id?'active':''}" data-tab="${id}">${name}</button>`).join('')}</nav><div class="header-right"><span class="save-indicator">● 本地存档</span><button id="sound" aria-label="切换音效">${state.sound?'♪ 音效开':'♩ 音效关'}</button></div></header>
<main><div class="heading"><div><p class="eyebrow">THE CHRONICLES OF PYTHON · 教学冒险 RPG</p><h1>${state.tab==='world'?'以代码为剑,以智慧为光。':state.tab==='book'?'每一道法术,都有迹可循。':'让知识,成为你的力量。'}</h1><p class="subtitle">${state.tab==='world'?'穿越符文王国,与巫师同行,解开恶龙与知识的古老契约。':'对应 Python 程序设计 16 周课程 / 32 关冒险 · 理解、实践、验证与反思'}</p></div><div class="progress-head"><span>符文收集</span><strong>${String(state.done.length).padStart(2,'0')}<small> / ${total}</small></strong><div class="progress-line"><i style="width:${state.done.length/total*100}%"></i></div></div></div>
${state.tab==='world'?`<div class="game-layout"><section class="world-panel"><div class="world-top"><span><i class="live-dot"></i> ${escape(c.place)} <small>· ${state.chapter<5?'翡翠原野':state.chapter<11?'符文腹地':'龙眠边境'}</small></span><span>CHAPTER ${String(state.chapter+1).padStart(2,'0')}</span></div><div id="scene" class="scene" tabindex="0" aria-label="游戏地图,方向键或WASD移动,靠近巫师按E交互" style="background-image:url('${scenery(state.chapter)}');background-size:200% 200%;background-position:${sceneryPosition(state.chapter)}"><div class="scene-shade"></div><div class="location-label"><span>✦ 知识之旅</span><h2>${escape(c.place)}</h2></div><div class="map-marker wizard-marker" style="left:35%;top:64%"><span class="bubble">E · 与巫师交谈</span><div class="sprite wizard"></div><small>巫师 · 赛勒斯</small></div>${state.chapter>=28?`<div class="map-marker dragon-marker" style="left:84%;top:56%"><span class="hotspot-ring" data-hotspot="dragon"></span><div class="sprite dragon"></div><small>诺克斯 · ${state.done.length===total?'守护者':'诅咒之龙'}</small></div>`:''}<div class="hotspot-ring" data-hotspot="wizard" style="left:35%;top:64%"></div><button class="map-hotspot trial-hotspot" data-interact="trial" style="left:64%;top:72%" aria-label="法术碑交互热区"><span class="hotspot-ring" data-hotspot="trial"></span><span class="stone-rune">✧</span><small>法术碑</small></button><button class="map-hotspot portal-hotspot" data-interact="portal" style="left:80%;top:76%" aria-label="旅途之门交互热区"><span class="hotspot-ring" data-hotspot="portal"></span><span class="portal-glyph">◇</span><small>旅途之门</small></button><div id="proximity-hint" class="proximity-hint" role="status"></div><div id="player" class="map-marker player"><div class="sprite knight"></div><small>勇者 · 你</small></div><div class="scene-bottom"><span><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / 方向键 移动</span><span><kbd>E</kbd> 交互 · 点击地图行走</span></div><div class="particles">✦<span>✧</span><span>✦</span></div></div><div class="world-footer"><div><span class="avatar">✦</span><span><strong>见习符文勇者</strong><small>等级 ${1+state.done.length} · ${state.done.length*100} XP</small></span></div><div class="health">生命 <span>♥ ♥ ♥ ♥ ♥</span></div><button id="talk">与巫师交谈 →</button></div></section><aside class="quest-panel"><p class="eyebrow">CURRENT QUEST / 当前任务</p><div class="quest-number">${String(state.chapter+1).padStart(2,'0')}<span> / ${total}</span></div><h2>${escape(c.title.split(' · ')[0])}</h2><p class="quest-description">${escape(c.story)}</p><div class="quest-divider"></div><span class="label">本章学习</span><div class="topic-tags">${c.topics.split(' · ').map(t=>`<span>${escape(t)}</span>`).join('')}</div><div class="reward"><span class="rune-icon">✧</span><div><small>任务奖励</small><strong>${escape(c.rune)}符文 · 100 XP</strong></div></div><p class="trial-summary">试炼进度 · ${state.done.includes(state.chapter)?5:state.trials[state.chapter]} / 5 题</p><button class="primary" id="challenge">${state.done.includes(state.chapter)?'重温本章试炼':'开启知识试炼'} <span>→</span></button><button class="text-btn" id="read">阅读本章法术 ↗</button></aside></div>`:state.tab==='book'?`<section class="book-layout"><aside class="chapter-list">${chapters.map((a,i)=>`<button data-chapter="${i}" ${!canEnterLevel(state.done,i,total)?'disabled':''} class="${i===state.chapter?'selected':''}"><span>${String(i+1).padStart(2,'0')}</span>${escape(a.place)}<small>${state.done.includes(i)?'✓':!canEnterLevel(state.done,i,total)?'🔒':'→'}</small></button>`).join('')}</aside><article class="lesson"><p class="eyebrow">WEEK ${c.week} · ${c.stage} / ${escape(c.topics)}</p><h2>${escape(c.title)}</h2><p>${escape(c.lesson)}</p><pre><code>${escape(c.code)}</code></pre><div class="assignment"><strong>✎ 现实世界的实践</strong><p>${escape(c.task)}</p><small>游戏中的试炼用于概念自测;本机编程、报告与课程提交需另外完成。</small></div>${(state.chapter===14||state.chapter===15)?regexHTML():''}<button class="primary" id="challenge">进入本章试炼 →</button></article></section>`:`<section class="rune-grid">${chapters.map((a,i)=>`<button data-rune="${i}" ${!canEnterLevel(state.done,i,total)?'disabled':''} class="rune-card ${state.done.includes(i)?'collected':''}"><span>${state.done.includes(i)?'✧':'◇'}</span><small>WEEK ${a.week} · ${a.stage}</small><strong>${escape(a.rune)}符文</strong><p>${escape(a.place)}</p><em>${state.done.includes(i)?'已点亮 · 查看法术':canEnterLevel(state.done,i,total)?'当前关 · 待收集':'🔒 完成前一关解锁'}</em></button>`).join('')}</section>`}
<section class="journey"><div class="journey-title"><span>你的冒险路线</span><small>32 关卡 · 一场关于创造与责任的旅程</small></div><div class="route">${chapters.map((a,i)=>`<button data-route="${i}" class="${i===state.chapter?'current':''} ${state.done.includes(i)?'done':''}" ${!canEnterLevel(state.done,i,total)?'disabled':''} title="${escape(a.title)}"><span>${state.done.includes(i)?'✓':String(i+1).padStart(2,'0')}</span><small>${escape(a.place)}</small></button>`).join('')}</div></section>
<footer><span>✦ Python 符文纪事 <small>知识为光,探索不息。</small></span><span>研发: 徐志平 复旦大学 计算与智能创新学院 2026</span><button id="export">导出学习记录 ↗</button></footer></main><div id="overlay"></div><div id="toast" role="status"></div>`;
document.documentElement.style.setProperty('--sprites',`url('${spritesUrl}')`);bind();position();const route=$('.route'),current=$('.route .current');if(route&¤t)route.scrollLeft=current.offsetLeft-route.clientWidth/2;}
function bind(){document.querySelectorAll('[data-tab]').forEach(b=>b.onclick=()=>{state.tab=b.dataset.tab;render();});document.querySelectorAll('[data-route]').forEach(b=>b.onclick=()=>{if(!canEnterLevel(state.done,+b.dataset.route,total))return;state.chapter=+b.dataset.route;render();});document.querySelectorAll('[data-chapter]').forEach(b=>b.onclick=()=>{if(!canEnterLevel(state.done,+b.dataset.chapter,total))return;state.chapter=+b.dataset.chapter;render();});document.querySelectorAll('[data-rune]').forEach(b=>b.onclick=()=>{if(!canEnterLevel(state.done,+b.dataset.rune,total))return;state.chapter=+b.dataset.rune;state.tab='book';render();});$('#sound').onclick=()=>{state.sound=!state.sound;tone();render();};$('#challenge')?.addEventListener('click',challenge);$('#talk')?.addEventListener('click',dialogue);$('.wizard-marker')?.addEventListener('click',dialogue);$('.dragon-marker')?.addEventListener('click',()=>state.done.length===total?ending():toast('集齐 32 枚知识符文,才能解除诺克斯的诅咒。'));$('#read')?.addEventListener('click',()=>{state.tab='book';render();});$('#scene')?.addEventListener('click',e=>{if(e.target.closest('.wizard-marker,.dragon-marker,.map-hotspot'))return;const r=$('#scene').getBoundingClientRect();player={x:Math.max(7,Math.min(93,(e.clientX-r.left)/r.width*100)),y:Math.max(50,Math.min(82,(e.clientY-r.top)/r.height*100))};position();$('#scene').focus();});document.querySelectorAll('[data-interact]').forEach(b=>b.onclick=e=>{e.stopPropagation();const h=hotspots.find(h=>h.id===b.dataset.interact);player={x:h.x,y:h.y};position();interactHotspot();});$('#export').onclick=()=>{const blob=new Blob([JSON.stringify({game:'Python符文纪事',completed:state.done.map(i=>({level:i+1,week:chapters[i].week,title:chapters[i].title,topics:chapters[i].topics})),exportedAt:new Date().toISOString()},null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='python-rune-learning.json';a.click();setTimeout(()=>URL.revokeObjectURL(a.href),1000);};$('#regex-run')?.addEventListener('click',runRegex);}
function position(){const p=$('#player');if(p){p.style.left=player.x+'%';p.style.top=player.y+'%';const near=nearestHotspot();document.querySelectorAll('[data-hotspot]').forEach(el=>el.classList.toggle('near',el.dataset.hotspot===near?.id));const hint=$('#proximity-hint');if(hint){hint.textContent=near?'E · '+near.label:'';hint.classList.toggle('visible',!!near);}}}
function toast(t){$('#toast').textContent=t;$('#toast').classList.add('show');setTimeout(()=>$('#toast')?.classList.remove('show'),3500);}
function show(content){modal=true;keys.clear();$('#overlay').innerHTML=`<div class="modal-backdrop"><section class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title"><button class="close" aria-label="关闭对话">×</button>${content}</section></div>`;$('.close').onclick=close;$('.close').focus();}
function close(){modal=false;$('#overlay').innerHTML='';render();$('#talk')?.focus();}
function dialogue(){const c=chapters[state.chapter];show(`<div class="dialogue-head"><div class="sprite wizard"></div><div><p class="eyebrow">S Y L A S / 符文导师</p><h2 id="modal-title">巫师 · 赛勒斯</h2></div></div><blockquote>“${escape(c.story)}”</blockquote><p>${escape(c.lesson)}</p><button class="primary" id="begin">我准备好了,开始试炼 →</button>`);$('#begin').onclick=challenge;}
function challenge(){
if(!canEnterLevel(state.done,state.chapter,total)){toast('请先完成前面的章节试炼。');return;}
activeTrial={chapter:state.chapter,index:state.done.includes(state.chapter)?0:Math.min(state.trials[state.chapter],4)};
renderTrial();
}
function renderTrial(){
const c=chapters[activeTrial.chapter],questions=questionsFor(c,activeTrial.chapter),index=activeTrial.index,q=questions[index];
show(`<p class="eyebrow">RUNE TRIAL · WEEK ${c.week} · ${c.stage}</p><h2 id="modal-title">${escape(c.title)}</h2><div class="trial-progress"><strong>题目 ${index+1} / ${questions.length}</strong><span>${questions.map((_,i)=>`<i class="${i<index?'answered':i===index?'current':''}">${i<index?'✓':i+1}</i>`).join('')}</span></div>${q.code?`<pre><code>${escape(q.code)}</code></pre>`:''}<h3>${escape(q.question)}</h3><div class="answers">${q.choices.map((t,i)=>`<button data-answer="${i}"><span>${'ABC'[i]}</span>${escape(t)}</button>`).join('')}</div><div id="feedback" role="status"></div><small class="trial-note">本关需完成全部 ${questions.length} 题。答错可重试,关闭后自动保存已答进度。</small>`);
document.querySelectorAll('[data-answer]').forEach(b=>b.onclick=()=>{
if(+b.dataset.answer!==q.answer){tone(180);b.classList.add('incorrect');$('#feedback').innerHTML='<p class="error">符文还未回应。回看题目,再想一想。</p>';return;}
tone(660);document.querySelectorAll('[data-answer]').forEach(a=>a.disabled=true);b.classList.add('correct');
const chapter=activeTrial.chapter,isLast=index===questions.length-1;
if(!state.done.includes(chapter)){state.trials[chapter]=index+1;if(isLast){state.done.push(chapter);state.done.sort((a,b)=>a-b);}save();}
$('#feedback').innerHTML=`<div class="success">${isLast?'✧ '+escape(c.rune)+'符文已点亮':'✓ 第 '+(index+1)+' 题完成'}<p>${escape(q.explain)}</p></div><button class="primary" id="${isLast?'next':'next-question'}">${isLast?(chapter===total-1?'解除恶龙的诅咒':'继续下一关'):'下一题 · '+(index+2)+' / '+questions.length} →</button>`;
if(!isLast){$('#next-question').onclick=()=>{activeTrial.index++;renderTrial();};return;}
$('#next').onclick=()=>{close();if(chapter===total-1){render();ending();}else{state.chapter=chapter+1;state.tab='world';player={x:48,y:72};render();}};
});
}
function ending(){show(`<p class="eyebrow">THE DAWN OF KNOWLEDGE</p><h2 id="modal-title">恶龙苏醒,黎明归来。</h2><div class="ending-dragon sprite dragon"></div><p>三十二枚符文照亮了龙眠城堡。诺克斯身上的错误咒语逐一散去,向你、艾琳和赛勒斯低下了头。从此,它不再吞噬知识,而是守护每一位敢于提问、验证与创造的人。</p><blockquote>“真正的勇者,不是从不犯错,而是愿意理解、修正,并为自己的代码负责。”</blockquote><p>概念试炼已完成。继续完成各周实践、综合项目和反思报告,让知识真正成为你的能力。</p><button class="primary" id="return">回到王国,重温法术 →</button>`);$('#return').onclick=()=>{close();state.tab='runes';render();};}
function regexHTML(){return `<section class="regex-lab"><h3>✧ 正则符文练习台</h3><p>支持本课常见模式,使用浏览器 JavaScript 正则引擎预览;Python 的 re 行为和高级语法以 Python 文档为准。</p><label>表达式<input id="pattern" value="1[3-9]\\d{9}" spellcheck="false"></label><label>待匹配文本<textarea id="regex-text">给巫师写信:mage@rune.edu,手机 13812345678,备用 13987654321。</textarea></label><button id="regex-run">施放匹配法术 →</button><output id="regex-output">修改表达式或文本,观察匹配结果。</output></section>`;}
function runRegex(){const pattern=$('#pattern').value,text=$('#regex-text').value;if(pattern.length>200||text.length>5000){$('#regex-output').textContent='表达式限 200 字符,文本限 5000 字符。';return;}const worker=new Worker(new URL('./regex-worker.js',import.meta.url),{type:'module'});const timer=setTimeout(()=>{worker.terminate();$('#regex-output').textContent='匹配超时,请简化表达式(避免嵌套重复)。';},500);worker.onmessage=e=>{clearTimeout(timer);worker.terminate();$('#regex-output').textContent=e.data.error?'表达式错误:'+e.data.error:e.data.matches.length?'匹配结果:'+JSON.stringify(e.data.matches):'没有匹配项。';};worker.postMessage({pattern,text});}
window.addEventListener('keydown',e=>{if(e.key==='Escape'&&modal){close();return;}if(modal||state.tab!=='world'||/INPUT|TEXTAREA/.test(e.target.tagName))return;if(['ArrowUp','ArrowDown','ArrowLeft','ArrowRight','w','a','s','d','W','A','S','D'].includes(e.key)){e.preventDefault();const key=e.key.toLowerCase();keys.add(key);if(!e.repeat){const dx=['a','arrowleft'].includes(key)?-1:['d','arrowright'].includes(key)?1:0;const dy=['w','arrowup'].includes(key)?-1:['s','arrowdown'].includes(key)?1:0;player.x=Math.max(7,Math.min(93,player.x+dx*.65));player.y=Math.max(50,Math.min(82,player.y+dy*.65));position();}}if(e.key.toLowerCase()==='e'){interactHotspot();}});window.addEventListener('keyup',e=>keys.delete(e.key.toLowerCase()));window.addEventListener('blur',()=>keys.clear());
function tick(t){const dt=Math.min((t-last)/1000,.05);last=t;if(!modal&&state.tab==='world'){let dx=0,dy=0;if(keys.has('a')||keys.has('arrowleft'))dx--;if(keys.has('d')||keys.has('arrowright'))dx++;if(keys.has('w')||keys.has('arrowup'))dy--;if(keys.has('s')||keys.has('arrowdown'))dy++;player.x=Math.max(7,Math.min(93,player.x+dx*dt*20));player.y=Math.max(50,Math.min(82,player.y+dy*dt*20));position();$('#player')?.classList.toggle('walking',!!(dx||dy));}requestAnimationFrame(tick);}render();requestAnimationFrame(tick);