Initial commit: Simple ASM - ARM assembly learning game

10-level progressive game teaching ARM assembly basics:
registers, arithmetic, bitwise ops, memory, branching, loops.
Vue 3 + FastAPI + SQLite with K8s deployment.
This commit is contained in:
2026-04-07 10:17:15 +01:00
commit e465b1cf71
29 changed files with 3515 additions and 0 deletions

View File

@@ -0,0 +1,266 @@
<template>
<div class="level-page">
<header class="level-header">
<router-link to="/levels" class="back-link">&larr; 返回</router-link>
<div class="hdr-center">
<span class="level-badge">{{ level.icon }} Level {{ level.id }}</span>
<h1>{{ level.title }}</h1>
</div>
<div class="hdr-stars">
<span v-for="s in 3" :key="s" :class="{ earned: s <= bestStars }">&#9733;</span>
</div>
</header>
<div class="level-layout">
<TutorialPanel :level="level" :visible="showTut" @toggle="showTut = !showTut" class="panel-tut" />
<div class="panel-center">
<CodeEditor v-model="code" :currentLine="curLine" :errorLine="errLine" :readOnly="mode !== 'edit'" />
<div class="controls">
<div class="ctrls-left">
<button class="cb run" @click="runCode" :disabled="mode === 'auto'">&#9654; 运行</button>
<button class="cb step" @click="stepCode" :disabled="mode === 'auto'">
&rarr;{{ mode === 'step' ? ' 下一步' : ' 单步' }}
</button>
<button v-if="mode === 'edit'" class="cb auto" @click="autoStep">&#9193; 动画</button>
<button v-if="mode !== 'edit'" class="cb reset" @click="resetVM">&#8634; 重置</button>
</div>
<div v-if="mode === 'auto'" class="speed">
<label>速度</label>
<input type="range" min="50" max="800" :value="800-delay" @input="delay=800-+$event.target.value">
</div>
</div>
<OutputConsole :output="out" :error="vmErr" :status="statusMsg" @clear="clearOut" />
</div>
<MachineState :registers="regs" :flags="fl" :memory="mem" :stack="stk"
:showMemory="level.showMemory" :memoryRange="level.memoryRange"
:changes="changes" class="panel-state" />
</div>
<div class="hints-bar">
<button class="hint-btn" @click="nextHint" :disabled="hintIdx >= level.hints.length - 1">
&#128161; 提示 ({{ Math.min(hintIdx+2, level.hints.length) }}/{{ level.hints.length }})
</button>
<div v-if="hintIdx >= 0" class="hint-text">{{ level.hints[hintIdx] }}</div>
</div>
<LevelComplete v-if="showComp" :level="level" :stars="earnedStars" :instructionCount="iCount"
@next="goNext" @retry="doRetry" @close="showComp = false" />
</div>
</template>
<script setup>
import { ref, reactive, computed, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '../stores/game.js'
import { levels } from '../lib/levels.js'
import { createVM, countInstructions, validateLevel, parse } from '../lib/vm.js'
import TutorialPanel from '../components/TutorialPanel.vue'
import CodeEditor from '../components/CodeEditor.vue'
import OutputConsole from '../components/OutputConsole.vue'
import MachineState from '../components/MachineState.vue'
import LevelComplete from '../components/LevelComplete.vue'
const props = defineProps({ id: [String, Number] })
const router = useRouter()
const store = useGameStore()
if (!store.isLoggedIn) router.replace('/')
const level = computed(() => levels.find(l => l.id === +props.id) || levels[0])
const bestStars = computed(() => store.progress[level.value.id]?.stars || 0)
const code = ref(store.progress[level.value.id]?.code || level.value.starterCode)
const mode = ref('edit')
const showTut = ref(true)
const showComp = ref(false)
const earnedStars = ref(0)
const hintIdx = ref(-1)
const delay = ref(200)
const EMPTY_REGS = { R0:0,R1:0,R2:0,R3:0,R4:0,R5:0,R6:0,R7:0 }
const regs = reactive({ ...EMPTY_REGS })
const fl = reactive({ zero:false, carry:false, negative:false, overflow:false })
const mem = ref(new Array(256).fill(0))
const stk = ref([])
const out = ref([])
const vmErr = ref('')
const statusMsg = ref('')
const curLine = ref(-1)
const errLine = ref(-1)
const changes = ref([])
const iCount = ref(0)
let vm = null
let autoTimer = null
function applyInitState() {
Object.assign(regs, EMPTY_REGS)
const m = new Array(256).fill(0)
const is = level.value.initialState
if (is) {
if (is.registers) Object.assign(regs, is.registers)
if (is.memory) for (const [a,v] of Object.entries(is.memory)) m[+a] = v
}
mem.value = m
fl.zero = false; fl.carry = false; fl.negative = false; fl.overflow = false
stk.value = []; out.value = []
}
function initVM() {
vm = createVM()
if (level.value.blockedOps) {
const p = parse(code.value)
for (const i of p.instructions) {
if (level.value.blockedOps.includes(i.opcode)) {
vmErr.value = `本关不能使用 ${i.opcode} 指令哦!`; return false
}
}
}
const r = vm.loadProgram(code.value)
if (vm.state.error) { vmErr.value = vm.state.error; if (r.errors?.length) errLine.value = r.errors[0].line; return false }
const is = level.value.initialState
if (is) {
if (is.registers) Object.assign(vm.state.registers, is.registers)
if (is.memory) for (const [a,v] of Object.entries(is.memory)) vm.state.memory[+a] = v
if (is.input) vm.state.input = [...is.input]
}
syncState(); return true
}
function syncState() {
if (!vm) return
Object.assign(regs, vm.state.registers)
Object.assign(fl, vm.state.flags)
mem.value = [...vm.state.memory]
stk.value = [...vm.state.stack]
out.value = [...vm.state.output]
if (vm.state.error) vmErr.value = vm.state.error
}
function runCode() {
stopAuto(); vmErr.value = ''; errLine.value = -1; mode.value = 'run'
if (!initVM()) { mode.value = 'edit'; return }
vm.run(); syncState(); curLine.value = -1
iCount.value = countInstructions(code.value)
if (vm.state.error) {
statusMsg.value = '运行出错了'
if (vm.state.pc < vm.state.instructions.length) errLine.value = vm.state.instructions[vm.state.pc]?.srcLine ?? -1
return
}
checkResult()
}
function stepCode() {
if (mode.value === 'edit' || mode.value === 'run') {
vmErr.value = ''; errLine.value = -1; mode.value = 'step'
if (!initVM()) { mode.value = 'edit'; return }
statusMsg.value = '单步执行中...'
}
if (vm.state.halted || vm.state.error) { iCount.value = countInstructions(code.value); checkResult(); return }
const r = vm.step()
if (r) { changes.value = r.changes || []; curLine.value = r.srcLine ?? -1 }
syncState()
if (vm.state.halted || vm.state.error) { iCount.value = countInstructions(code.value); if (vm.state.halted) checkResult() }
}
function autoStep() {
vmErr.value = ''; errLine.value = -1; mode.value = 'auto'
if (!initVM()) { mode.value = 'edit'; return }
statusMsg.value = '动画执行中...'; doAuto()
}
function doAuto() {
if (!vm || vm.state.halted || vm.state.error || mode.value !== 'auto') {
if (vm?.state.halted) { iCount.value = countInstructions(code.value); checkResult() }
syncState(); return
}
const r = vm.step()
if (r) { changes.value = r.changes || []; curLine.value = r.srcLine ?? -1 }
syncState()
autoTimer = setTimeout(doAuto, delay.value)
}
function stopAuto() { if (autoTimer) { clearTimeout(autoTimer); autoTimer = null } }
function resetVM() {
stopAuto(); vm = null; mode.value = 'edit'
curLine.value = -1; errLine.value = -1; vmErr.value = ''; statusMsg.value = ''
changes.value = []; applyInitState()
}
function clearOut() { out.value = []; vmErr.value = ''; statusMsg.value = '' }
function checkResult() {
const tv = createVM(); tv.loadProgram(code.value)
const r = validateLevel(level.value, tv)
if (r.passed) {
iCount.value = countInstructions(code.value)
const [s3, s2] = level.value.starThresholds
earnedStars.value = iCount.value <= s3 ? 3 : iCount.value <= s2 ? 2 : 1
store.saveProgress(level.value.id, earnedStars.value, code.value)
showComp.value = true; statusMsg.value = '通关!'
} else {
vmErr.value = r.msg; statusMsg.value = '还没通过,再试试!'
}
}
function nextHint() { if (hintIdx.value < level.value.hints.length - 1) hintIdx.value++ }
function goNext() { showComp.value = false; const n = level.value.id + 1; router.push(n <= 10 ? `/level/${n}` : '/levels') }
function doRetry() { showComp.value = false; resetVM() }
watch(() => props.id, () => {
stopAuto(); vm = null; mode.value = 'edit'
code.value = store.progress[level.value.id]?.code || level.value.starterCode
hintIdx.value = -1; showComp.value = false; curLine.value = -1; errLine.value = -1
vmErr.value = ''; statusMsg.value = ''; changes.value = []; applyInitState()
})
applyInitState()
onUnmounted(() => stopAuto())
</script>
<style scoped>
.level-page { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
.level-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 20px; background: var(--bg-card); border-bottom: 1px solid var(--border); flex-shrink: 0; }
.back-link { color: var(--text-secondary); font-size: 14px; min-width: 60px; }
.hdr-center { text-align: center; }
.level-badge { font-size: 12px; color: var(--text-muted); }
.level-header h1 { font-size: 18px; font-weight: 600; }
.hdr-stars { display: flex; gap: 4px; font-size: 20px; min-width: 60px; justify-content: flex-end; }
.hdr-stars span { color: var(--border); }
.hdr-stars span.earned { color: var(--accent-yellow); }
.level-layout { flex: 1; display: grid; grid-template-columns: 260px 1fr 300px; gap: 10px; padding: 10px; overflow: hidden; min-height: 0; }
.panel-tut { overflow-y: auto; min-height: 0; }
.panel-center { display: flex; flex-direction: column; gap: 6px; min-height: 0; overflow: hidden; }
.panel-center .code-editor { flex: 1; min-height: 0; overflow: hidden; }
.panel-state { overflow-y: auto; min-height: 0; }
.controls { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-shrink: 0; }
.ctrls-left { display: flex; gap: 5px; }
.cb { padding: 6px 12px; font-size: 13px; font-weight: 500; }
.cb.run { background: var(--accent-green); color: #fff; }
.cb.run:hover { background: #0d9668; }
.cb.step { background: var(--accent-blue); color: #fff; }
.cb.step:hover { background: #2563eb; }
.cb.auto { background: var(--accent-purple); color: #fff; }
.cb.auto:hover { background: #7c3aed; }
.cb.reset { background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border); }
.cb.reset:hover { background: var(--bg-hover); }
.cb:disabled { opacity: 0.5; cursor: not-allowed; }
.speed { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); }
.speed input[type="range"] { width: 100px; accent-color: var(--accent-purple); }
.hints-bar { display: flex; align-items: center; gap: 12px; padding: 7px 20px; background: var(--bg-card); border-top: 1px solid var(--border); flex-shrink: 0; }
.hint-btn { padding: 5px 14px; background: rgba(245,158,11,0.1); color: var(--accent-yellow); border: 1px solid rgba(245,158,11,0.3); font-size: 13px; white-space: nowrap; flex-shrink: 0; }
.hint-btn:hover:not(:disabled) { background: rgba(245,158,11,0.2); }
.hint-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.hint-text { font-size: 13px; color: var(--accent-yellow); font-family: var(--font-mono); }
@media (max-width: 1024px) {
.level-layout { grid-template-columns: 1fr; }
.panel-tut { max-height: 200px; }
.panel-state { max-height: 250px; }
}
</style>