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:
266
frontend/src/views/LevelView.vue
Normal file
266
frontend/src/views/LevelView.vue
Normal file
@@ -0,0 +1,266 @@
|
||||
<template>
|
||||
<div class="level-page">
|
||||
<header class="level-header">
|
||||
<router-link to="/levels" class="back-link">← 返回</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 }">★</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'">▶ 运行</button>
|
||||
<button class="cb step" @click="stepCode" :disabled="mode === 'auto'">
|
||||
→{{ mode === 'step' ? ' 下一步' : ' 单步' }}
|
||||
</button>
|
||||
<button v-if="mode === 'edit'" class="cb auto" @click="autoStep">⏩ 动画</button>
|
||||
<button v-if="mode !== 'edit'" class="cb reset" @click="resetVM">↺ 重置</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">
|
||||
💡 提示 ({{ 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>
|
||||
Reference in New Issue
Block a user