app #1 simpleasm: 从 oci 迁过来,asm.famzheng.me 已上线
- 后端 FastAPI 重写为 axum + rusqlite (musl static, 2.8MB) - 前端原样搬运 (Vue3 + Vite + Pinia + vue-router + vite-plugin-yaml) - k8s: cube-simpleasm ns + 1Gi PVC (k3s local-path) + Recreate strategy - CI: 复刻 deploy-cube.yml,按 apps/simpleasm/** 触发 - cube 门户里 simpleasm 状态从 pending 改成 live - 数据冷启 (Fam 拍板不带历史进度)
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--bg-dark: #0a0e1a;
|
||||
--bg-card: #141b2d;
|
||||
--bg-surface: #1e2742;
|
||||
--bg-hover: #253352;
|
||||
--border: #2a3655;
|
||||
--text-primary: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-cyan: #06b6d4;
|
||||
--accent-green: #10b981;
|
||||
--accent-yellow: #f59e0b;
|
||||
--accent-red: #ef4444;
|
||||
--accent-purple: #8b5cf6;
|
||||
--accent-pink: #ec4899;
|
||||
--font-mono: 'JetBrains Mono', 'Courier New', monospace;
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
--radius: 8px;
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background: var(--bg-dark);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
a { color: var(--accent-cyan); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
button {
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 16px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
code, pre, .mono { font-family: var(--font-mono); }
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div class="code-editor" :class="{ running: readOnly }">
|
||||
<div class="editor-header">
|
||||
<span>Code</span>
|
||||
<span class="line-count mono">{{ instrCount }} {{ instrCount === 1 ? 'instruction' : 'instructions' }}</span>
|
||||
</div>
|
||||
<div class="editor-body">
|
||||
<div class="line-numbers" ref="lineNumsEl">
|
||||
<div v-for="n in totalLines" :key="n" class="ln"
|
||||
:class="{ current: n-1 === currentLine, error: n-1 === errorLine }">{{ n }}</div>
|
||||
</div>
|
||||
<textarea
|
||||
v-if="!readOnly"
|
||||
ref="ta"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
@scroll="syncScroll"
|
||||
@keydown.tab.prevent="insertTab"
|
||||
spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off"
|
||||
></textarea>
|
||||
<pre v-else class="code-display" ref="codeDisplay" @scroll="syncScroll"><code><template
|
||||
v-for="(line, i) in displayLines" :key="i"
|
||||
><span class="cl" :class="{ current: i === currentLine, error: i === errorLine }"
|
||||
v-html="hl(line)"></span>
|
||||
</template></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, nextTick, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: String,
|
||||
currentLine: { type: Number, default: -1 },
|
||||
errorLine: { type: Number, default: -1 },
|
||||
readOnly: Boolean,
|
||||
})
|
||||
defineEmits(['update:modelValue'])
|
||||
|
||||
const ta = ref(null), lineNumsEl = ref(null), codeDisplay = ref(null)
|
||||
|
||||
const totalLines = computed(() => (props.modelValue||'').split('\n').length)
|
||||
const displayLines = computed(() => (props.modelValue||'').split('\n'))
|
||||
const instrCount = computed(() => {
|
||||
let c = 0
|
||||
for (const line of (props.modelValue||'').split('\n')) {
|
||||
let l = line; const ci = l.indexOf(';'); if (ci >= 0) l = l.slice(0, ci); l = l.trim()
|
||||
if (!l) continue
|
||||
const lm = l.match(/^[a-zA-Z_]\w*\s*:(.*)$/); if (lm) { l = lm[1].trim(); if (!l) continue }
|
||||
c++
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
function hl(line) {
|
||||
let h = line.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||||
h = h.replace(/(;.*)$/, '<span class="sc">$1</span>')
|
||||
h = h.replace(/^(\s*\w+\s*:)/, '<span class="sl">$1</span>')
|
||||
h = h.replace(/\b(MOV|ADD|SUB|MUL|XDIV|XMOD|AND|ORR|EOR|MVN|LSL|LSR|CMP|BEQ|BNE|BGT|BLT|BGE|BLE|LDR|STR|PUSH|POP|XOUT|XHLT|NOP|B)\b/gi,
|
||||
'<span class="sk">$1</span>')
|
||||
h = h.replace(/\b(R[0-7])\b/gi, '<span class="sr">$1</span>')
|
||||
h = h.replace(/(\[[^\]]+\])/g, '<span class="sm">$1</span>')
|
||||
h = h.replace(/(#(?:0x[0-9a-fA-F]+|0b[01]+|\d+))/g, '<span class="sn">$1</span>')
|
||||
// Bare numbers not already highlighted
|
||||
h = h.replace(/(?<![#\w])\b(\d+)\b(?![0-9a-fA-F])/g, '<span class="sn">$1</span>')
|
||||
return h || ' '
|
||||
}
|
||||
|
||||
function syncScroll(e) { if (lineNumsEl.value) lineNumsEl.value.scrollTop = e.target.scrollTop }
|
||||
function insertTab() {
|
||||
const el = ta.value; if (!el) return
|
||||
const s = el.selectionStart, e2 = el.selectionEnd, v = props.modelValue
|
||||
el.value = v.substring(0,s) + ' ' + v.substring(e2)
|
||||
el.selectionStart = el.selectionEnd = s + 2
|
||||
el.dispatchEvent(new Event('input'))
|
||||
}
|
||||
|
||||
watch(() => props.currentLine, async (line) => {
|
||||
if (line < 0) return; await nextTick()
|
||||
const c = codeDisplay.value; if (!c) return
|
||||
const el = c.querySelectorAll('.cl')[line]; if (el) el.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-editor { display: flex; flex-direction: column; background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius-lg); overflow: hidden; min-height: 0; }
|
||||
.editor-header { display: flex; justify-content: space-between; padding: 6px 14px; background: var(--bg-surface); font-size: 12px; color: var(--text-muted); border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.editor-body { display: flex; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.line-numbers { flex-shrink: 0; padding: 10px 0; background: var(--bg-dark); user-select: none; min-width: 36px; overflow: hidden; border-right: 1px solid var(--border); }
|
||||
.ln { padding: 0 8px 0 10px; font-family: var(--font-mono); font-size: 13px; line-height: 1.6; color: var(--text-muted); text-align: right; }
|
||||
.ln.current { color: var(--accent-yellow); background: rgba(245,158,11,0.1); }
|
||||
.ln.error { color: var(--accent-red); background: rgba(239,68,68,0.1); }
|
||||
textarea { flex: 1; padding: 10px 14px; background: var(--bg-dark); border: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 13px; line-height: 1.6; resize: none; outline: none; tab-size: 2; white-space: pre; overflow: auto; }
|
||||
.code-display { flex: 1; padding: 10px 14px; background: var(--bg-dark); margin: 0; overflow: auto; min-height: 0; }
|
||||
.code-display code { display: block; }
|
||||
.cl { display: block; font-size: 13px; line-height: 1.6; padding: 0 4px; border-radius: 2px; white-space: pre; min-height: 1.6em; }
|
||||
.cl.current { background: rgba(245,158,11,0.15); border-left: 2px solid var(--accent-yellow); padding-left: 2px; }
|
||||
.cl.error { background: rgba(239,68,68,0.15); border-left: 2px solid var(--accent-red); padding-left: 2px; }
|
||||
:deep(.sk) { color: var(--accent-blue); font-weight: 600; }
|
||||
:deep(.sr) { color: var(--accent-cyan); }
|
||||
:deep(.sn) { color: var(--accent-yellow); }
|
||||
:deep(.sc) { color: var(--text-muted); font-style: italic; }
|
||||
:deep(.sm) { color: var(--accent-purple); }
|
||||
:deep(.sl) { color: var(--accent-pink); }
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div class="overlay" @click.self="$emit('close')">
|
||||
<div class="confetti" ref="confettiEl"></div>
|
||||
<div class="card">
|
||||
<div class="badge">🎉</div>
|
||||
<h2>Level Complete!</h2>
|
||||
<p class="lname">{{ level.title }}</p>
|
||||
|
||||
<div class="stars-row">
|
||||
<span v-for="s in 3" :key="s"
|
||||
class="star" :class="{ earned: s <= stars }"
|
||||
:style="{ animationDelay: s * 0.3 + 's' }">★</span>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="sl">Instructions</span><span class="sv">{{ instructionCount }}</span></div>
|
||||
<div class="stat"><span class="sl">Stars Earned</span><span class="sv">{{ stars }} / 3</span></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn-retry" @click="$emit('retry')">Try Again</button>
|
||||
<button v-if="level.id < 10" class="btn-next" @click="$emit('next')">Next Level →</button>
|
||||
<button v-else class="btn-next" @click="$emit('close')">Back to Levels</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
defineProps({ level: Object, stars: Number, instructionCount: Number })
|
||||
defineEmits(['next', 'retry', 'close'])
|
||||
|
||||
const confettiEl = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
if (!confettiEl.value) return
|
||||
const colors = ['#3b82f6','#06b6d4','#10b981','#f59e0b','#ef4444','#8b5cf6','#ec4899']
|
||||
for (let i = 0; i < 60; i++) {
|
||||
const p = document.createElement('div')
|
||||
p.className = 'cp'
|
||||
p.style.left = `${Math.random() * 100}%`
|
||||
p.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]
|
||||
p.style.animationDuration = `${2 + Math.random() * 3}s`
|
||||
p.style.animationDelay = `${Math.random() * 0.8}s`
|
||||
p.style.width = `${6 + Math.random() * 8}px`
|
||||
p.style.height = `${6 + Math.random() * 8}px`
|
||||
confettiEl.value.appendChild(p)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.overlay {
|
||||
position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.confetti { position: fixed; inset: 0; pointer-events: none; overflow: hidden; }
|
||||
:deep(.cp) {
|
||||
position: absolute; top: -20px; border-radius: 2px;
|
||||
animation: cf linear forwards;
|
||||
}
|
||||
@keyframes cf {
|
||||
0% { transform: translateY(0) rotate(0deg); opacity: 1; }
|
||||
100% { transform: translateY(100vh) rotate(720deg); opacity: 0; }
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 40px;
|
||||
text-align: center; max-width: 400px; width: 90%;
|
||||
position: relative; z-index: 1;
|
||||
animation: cardPop 0.4s cubic-bezier(0.175,0.885,0.32,1.275);
|
||||
}
|
||||
@keyframes cardPop { from { transform: scale(0.8); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||
|
||||
.badge { font-size: 56px; margin-bottom: 4px; }
|
||||
h2 { font-size: 24px; margin-bottom: 4px; }
|
||||
.lname { color: var(--text-secondary); margin-bottom: 24px; }
|
||||
|
||||
.stars-row { display: flex; justify-content: center; gap: 12px; margin-bottom: 24px; }
|
||||
.star {
|
||||
font-size: 48px; color: var(--border);
|
||||
animation: sp 0.5s cubic-bezier(0.175,0.885,0.32,1.275) both;
|
||||
}
|
||||
.star.earned { color: var(--accent-yellow); text-shadow: 0 0 20px rgba(245,158,11,0.5); }
|
||||
@keyframes sp { 0% { transform: scale(0) rotate(-180deg); opacity: 0; } 100% { transform: scale(1) rotate(0); opacity: 1; } }
|
||||
|
||||
.stats { display: flex; justify-content: center; gap: 32px; margin-bottom: 28px; }
|
||||
.stat { text-align: center; }
|
||||
.sl { display: block; font-size: 12px; color: var(--text-muted); margin-bottom: 4px; }
|
||||
.sv { font-family: var(--font-mono); font-size: 20px; font-weight: 600; }
|
||||
|
||||
.actions { display: flex; gap: 12px; justify-content: center; }
|
||||
.btn-retry { padding: 10px 24px; background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border); }
|
||||
.btn-retry:hover { background: var(--bg-hover); }
|
||||
.btn-next { padding: 10px 24px; background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan)); color: white; font-weight: 600; }
|
||||
.btn-next:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(59,130,246,0.3); }
|
||||
</style>
|
||||
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="machine-state">
|
||||
<h3>⚙ Machine State</h3>
|
||||
|
||||
<div class="section">
|
||||
<h4>Registers</h4>
|
||||
<div class="registers">
|
||||
<div v-for="(reg, idx) in regs" :key="reg"
|
||||
class="register" :class="{ changed: changedRegs.has(reg) }"
|
||||
:style="{ borderLeftColor: regColors[idx] }">
|
||||
<span class="reg-label" :style="{ color: regColors[idx] }">{{ reg }}</span>
|
||||
<span class="reg-dec">{{ registers[reg] }}</span>
|
||||
<span class="reg-hex">{{ hex(registers[reg]) }}</span>
|
||||
<span class="reg-bin">{{ bin(registers[reg]) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h4>Flags</h4>
|
||||
<div class="flags-row">
|
||||
<div class="flag" :class="{ on: flags.zero }"><span class="led"></span><span>Z</span></div>
|
||||
<div class="flag" :class="{ on: flags.carry }"><span class="led"></span><span>C</span></div>
|
||||
<div class="flag" :class="{ on: flags.negative }"><span class="led"></span><span>N</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="stack && stack.length" class="section">
|
||||
<h4>Stack ({{ stack.length }})</h4>
|
||||
<div class="stack-list">
|
||||
<div v-for="(v, i) in stackRev" :key="i" class="stack-item">{{ v }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showMemory" class="section">
|
||||
<h4>Memory</h4>
|
||||
<div class="memory-grid">
|
||||
<div class="mg-header">
|
||||
<span class="mg-corner"></span>
|
||||
<span v-for="c in 16" :key="c" class="mg-ch">{{ (c-1).toString(16).toUpperCase() }}</span>
|
||||
</div>
|
||||
<div v-for="row in memRows" :key="row" class="mg-row">
|
||||
<span class="mg-addr">{{ (row*16).toString(16).toUpperCase().padStart(2,'0') }}</span>
|
||||
<span v-for="col in 16" :key="col"
|
||||
class="mg-cell"
|
||||
:class="{ nz: memory[row*16+col-1] !== 0, cg: changedMem.has(row*16+col-1) }">
|
||||
{{ memory[row*16+col-1].toString(16).toUpperCase().padStart(2,'0') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
registers: Object, flags: Object, memory: Array, stack: Array,
|
||||
showMemory: Boolean, memoryRange: { type: Array, default: () => [0,31] }, changes: Array,
|
||||
})
|
||||
|
||||
const regs = ['R0','R1','R2','R3','R4','R5','R6','R7']
|
||||
const regColors = ['#06b6d4','#8b5cf6','#f59e0b','#ec4899','#10b981','#3b82f6','#f97316','#ef4444']
|
||||
const changedRegs = ref(new Set())
|
||||
const changedMem = ref(new Set())
|
||||
|
||||
const stackRev = computed(() => props.stack ? [...props.stack].reverse() : [])
|
||||
const memRows = computed(() => {
|
||||
if (!props.showMemory) return []
|
||||
const s = Math.floor((props.memoryRange?.[0]??0)/16)
|
||||
const e = Math.floor((props.memoryRange?.[1]??31)/16)
|
||||
return Array.from({ length: e-s+1 }, (_,i) => s+i)
|
||||
})
|
||||
|
||||
function hex(v) { return '0x' + (v&0xFFFF).toString(16).toUpperCase().padStart(4,'0') }
|
||||
function bin(v) {
|
||||
const b = (v&0xFFFF).toString(2).padStart(16,'0')
|
||||
return b.slice(0,4)+' '+b.slice(4,8)+' '+b.slice(8,12)+' '+b.slice(12)
|
||||
}
|
||||
|
||||
watch(() => props.changes, (ch) => {
|
||||
changedRegs.value = new Set(); changedMem.value = new Set()
|
||||
if (!ch) return
|
||||
for (const c of ch) {
|
||||
if (c.type === 'reg') changedRegs.value.add(c.name)
|
||||
if (c.type === 'mem') changedMem.value.add(c.addr)
|
||||
}
|
||||
setTimeout(() => { changedRegs.value = new Set(); changedMem.value = new Set() }, 600)
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.machine-state {
|
||||
background: var(--bg-card); border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg); padding: 14px; overflow-y: auto;
|
||||
}
|
||||
.machine-state > h3 { font-size: 13px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 1px; margin-bottom: 14px; }
|
||||
.section { margin-bottom: 16px; }
|
||||
.section h4 { font-size: 11px; color: var(--text-muted); margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
.registers { display: flex; flex-direction: column; gap: 3px; }
|
||||
.register {
|
||||
display: grid; grid-template-columns: 28px 44px 58px 1fr;
|
||||
align-items: center; gap: 4px;
|
||||
padding: 4px 6px; background: var(--bg-surface); border-radius: 4px;
|
||||
border-left: 3px solid var(--border); transition: background 0.3s;
|
||||
font-family: var(--font-mono); font-size: 12px;
|
||||
}
|
||||
.register.changed { animation: regfl 0.5s ease; }
|
||||
@keyframes regfl { 0%,100% { background: var(--bg-surface); } 50% { background: rgba(59,130,246,0.2); } }
|
||||
.reg-label { font-weight: 700; font-size: 11px; }
|
||||
.reg-dec { font-weight: 600; text-align: right; font-size: 13px; }
|
||||
.reg-hex { color: var(--text-muted); font-size: 10px; }
|
||||
.reg-bin { color: var(--text-muted); font-size: 9px; letter-spacing: 0.5px; }
|
||||
|
||||
.flags-row { display: flex; gap: 8px; }
|
||||
.flag {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 10px; background: var(--bg-surface); border-radius: var(--radius);
|
||||
font-family: var(--font-mono); font-weight: 600; font-size: 13px;
|
||||
}
|
||||
.led { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); transition: all 0.3s; }
|
||||
.flag.on .led { background: var(--accent-green); box-shadow: 0 0 8px var(--accent-green); }
|
||||
|
||||
.stack-list { display: flex; flex-direction: column; gap: 2px; }
|
||||
.stack-item { font-family: var(--font-mono); padding: 3px 12px; background: var(--bg-surface); border-radius: 4px; font-size: 12px; text-align: center; }
|
||||
|
||||
.memory-grid { font-family: var(--font-mono); font-size: 10px; overflow-x: auto; }
|
||||
.mg-header, .mg-row { display: flex; gap: 1px; }
|
||||
.mg-header { margin-bottom: 1px; }
|
||||
.mg-corner { width: 22px; flex-shrink: 0; }
|
||||
.mg-ch { width: 20px; text-align: center; color: var(--text-muted); }
|
||||
.mg-addr { width: 22px; flex-shrink: 0; color: var(--text-muted); text-align: right; padding-right: 3px; }
|
||||
.mg-cell { width: 20px; text-align: center; padding: 2px 0; background: var(--bg-dark); border-radius: 1px; color: var(--text-muted); transition: all 0.3s; }
|
||||
.mg-cell.nz { color: var(--accent-cyan); background: rgba(6,182,212,0.08); }
|
||||
.mg-cell.cg { animation: mfl 0.5s ease; }
|
||||
@keyframes mfl { 0%,100% { background: rgba(6,182,212,0.08); } 50% { background: rgba(6,182,212,0.4); } }
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="output-console">
|
||||
<div class="console-header">
|
||||
<span>Output</span>
|
||||
<button v-if="messages.length" @click="$emit('clear')" class="clear-btn">Clear</button>
|
||||
</div>
|
||||
<div class="console-body" ref="bodyEl">
|
||||
<div v-for="(m, i) in messages" :key="i" class="console-line" :class="m.type">
|
||||
<span class="pfx">{{ m.pfx }}</span>
|
||||
<span>{{ m.text }}</span>
|
||||
</div>
|
||||
<div v-if="!messages.length" class="empty">Waiting to run...</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps({ output: Array, error: String, status: String })
|
||||
defineEmits(['clear'])
|
||||
const bodyEl = ref(null)
|
||||
|
||||
const messages = computed(() => {
|
||||
const msgs = []
|
||||
if (props.status) msgs.push({ type: 'info', pfx: '>', text: props.status })
|
||||
for (const v of (props.output || [])) msgs.push({ type: 'output', pfx: 'OUT', text: String(v) })
|
||||
if (props.error) msgs.push({ type: 'error', pfx: '!', text: props.error })
|
||||
return msgs
|
||||
})
|
||||
|
||||
watch(messages, async () => {
|
||||
await nextTick()
|
||||
if (bodyEl.value) bodyEl.value.scrollTop = bodyEl.value.scrollHeight
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.output-console {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.console-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 5px 14px;
|
||||
background: var(--bg-surface);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.clear-btn { padding: 2px 8px; font-size: 11px; background: transparent; color: var(--text-muted); border: 1px solid var(--border); }
|
||||
.console-body {
|
||||
padding: 8px 14px;
|
||||
min-height: 40px;
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
background: var(--bg-dark);
|
||||
}
|
||||
.console-line { padding: 1px 0; display: flex; gap: 8px; }
|
||||
.pfx { color: var(--text-muted); flex-shrink: 0; min-width: 28px; }
|
||||
.console-line.output { color: var(--accent-green); }
|
||||
.console-line.error { color: var(--accent-red); }
|
||||
.console-line.info { color: var(--accent-cyan); }
|
||||
.empty { color: var(--text-muted); font-style: italic; font-size: 12px; }
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
<template>
|
||||
<div class="tutorial-panel" :class="{ collapsed: !visible }">
|
||||
<button class="toggle-btn" @click="$emit('toggle')" :title="visible ? 'Collapse' : 'Expand tutorial'">
|
||||
{{ visible ? '◀' : '▶' }}
|
||||
</button>
|
||||
<div v-show="visible" class="tutorial-content">
|
||||
<div class="goal-box">
|
||||
<h4>🎯 Goal</h4>
|
||||
<p v-html="fmt(level.goal)"></p>
|
||||
</div>
|
||||
<div v-for="(sec, i) in level.tutorial" :key="i" class="tut-section">
|
||||
<h4>{{ sec.title }}</h4>
|
||||
<p v-html="fmt(sec.text)"></p>
|
||||
<pre v-if="sec.code" class="tut-code"><code>{{ sec.code }}</code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({ level: Object, visible: Boolean })
|
||||
defineEmits(['toggle'])
|
||||
|
||||
function fmt(t) {
|
||||
if (!t) return ''
|
||||
return t
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tutorial-panel {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: min-width 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tutorial-panel.collapsed { min-width: 32px !important; max-width: 32px !important; }
|
||||
.toggle-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 4px;
|
||||
z-index: 2;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.tutorial-content {
|
||||
padding: 16px;
|
||||
padding-right: 12px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
.goal-box {
|
||||
background: rgba(59,130,246,0.08);
|
||||
border: 1px solid rgba(59,130,246,0.2);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.goal-box h4 { margin-bottom: 6px; font-size: 14px; }
|
||||
.goal-box p { font-size: 14px; line-height: 1.5; color: var(--text-secondary); }
|
||||
.goal-box :deep(strong) { color: var(--accent-yellow); }
|
||||
.tut-section { margin-bottom: 18px; }
|
||||
.tut-section h4 { font-size: 14px; margin-bottom: 6px; color: var(--accent-cyan); }
|
||||
.tut-section p { font-size: 13px; line-height: 1.6; color: var(--text-secondary); }
|
||||
.tut-section :deep(code) {
|
||||
font-family: var(--font-mono);
|
||||
background: var(--bg-surface);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
color: var(--accent-yellow);
|
||||
}
|
||||
.tut-section :deep(strong) { color: var(--text-primary); font-weight: 600; }
|
||||
.tut-code {
|
||||
background: var(--bg-dark);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
margin-top: 8px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.tut-code code { font-size: 12px; line-height: 1.5; color: var(--accent-green); }
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
const modules = import.meta.glob('./levels/*.yaml', { eager: true })
|
||||
|
||||
export const levels = Object.values(modules)
|
||||
.map(m => m.default)
|
||||
.sort((a, b) => a.id - b.id)
|
||||
@@ -0,0 +1,50 @@
|
||||
id: 1
|
||||
title: Meet the Registers
|
||||
subtitle: The robot's memory slots
|
||||
description: Learn the MOV instruction to load values into registers
|
||||
|
||||
tutorial:
|
||||
- title: What is a register?
|
||||
text: >
|
||||
The CPU is the brain of the computer, and **registers** are the tiny drawers
|
||||
right next to it — the fastest storage there is! Our machine has 8 registers:
|
||||
**R0** through **R7**.
|
||||
- title: The MOV instruction
|
||||
text: >
|
||||
`MOV` puts a number into a register. Numbers are prefixed with **#** to mark
|
||||
them as immediate values:
|
||||
code: |
|
||||
MOV R0, #42 ; put 42 into R0
|
||||
MOV R1, #100 ; put 100 into R1
|
||||
- title: The XHLT instruction
|
||||
text: >
|
||||
Every program ends with `XHLT` (halt = stop) to tell the machine "we're done!"
|
||||
code: |
|
||||
MOV R0, #42
|
||||
XHLT
|
||||
|
||||
goal: Put the number **42** into the **R0** register
|
||||
|
||||
initialState: {}
|
||||
|
||||
testCases:
|
||||
- init: {}
|
||||
expected:
|
||||
registers:
|
||||
R0: 42
|
||||
|
||||
hints:
|
||||
- "MOV format: MOV register, #number"
|
||||
- "Try: MOV R0, #???"
|
||||
- "Answer: MOV R0, #42 then XHLT"
|
||||
|
||||
starThresholds: [2, 3, 5]
|
||||
|
||||
starterCode: |
|
||||
; Put 42 into the R0 register
|
||||
; Tip: prefix numbers with #
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: false
|
||||
@@ -0,0 +1,45 @@
|
||||
id: 2
|
||||
title: Data Mover
|
||||
subtitle: Copying between registers
|
||||
description: Learn how to copy data between registers
|
||||
|
||||
tutorial:
|
||||
- title: Register-to-register copy
|
||||
text: >
|
||||
MOV can also **copy** the value of one register into another (no # needed):
|
||||
code: |
|
||||
MOV R1, R0 ; copy R0 into R1
|
||||
- title: It's a copy, not a move!
|
||||
text: >
|
||||
Despite being called "MOV" (move), it's actually a **copy**. After it runs
|
||||
R0 still holds its value, and R1 now matches it.
|
||||
|
||||
goal: R0 already holds **7** — copy it into both **R1** and **R2**
|
||||
|
||||
initialState:
|
||||
registers:
|
||||
R0: 7
|
||||
|
||||
testCases:
|
||||
- init: {}
|
||||
expected:
|
||||
registers:
|
||||
R0: 7
|
||||
R1: 7
|
||||
R2: 7
|
||||
|
||||
hints:
|
||||
- "MOV register, register — copies right into left"
|
||||
- "MOV R1, R0 copies R0 into R1"
|
||||
- "Answer: MOV R1, R0 / MOV R2, R0 / XHLT"
|
||||
|
||||
starThresholds: [3, 4, 6]
|
||||
|
||||
starterCode: |
|
||||
; R0 = 7
|
||||
; Copy R0 into R1 and R2
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: false
|
||||
@@ -0,0 +1,53 @@
|
||||
id: 3
|
||||
title: Add and Subtract
|
||||
subtitle: The power of three operands
|
||||
description: Learn the ADD and SUB instructions
|
||||
|
||||
tutorial:
|
||||
- title: ADD — three-operand addition
|
||||
text: >
|
||||
ARM-style addition is neat: **three operands**! The first is the destination,
|
||||
the other two are the values being combined:
|
||||
code: |
|
||||
ADD R2, R0, R1 ; R2 = R0 + R1
|
||||
ADD R0, R0, #10 ; R0 = R0 + 10
|
||||
- title: SUB — subtraction
|
||||
text: >
|
||||
SUB works the same way, three operands:
|
||||
code: |
|
||||
SUB R2, R0, R1 ; R2 = R0 - R1
|
||||
SUB R0, R0, #5 ; R0 = R0 - 5
|
||||
- title: Why three operands?
|
||||
text: >
|
||||
You can drop the result straight into a new register, **no extra copy needed**!
|
||||
|
||||
goal: R0=**15**, R1=**27** — compute R0+R1 into **R2** (R0 and R1 unchanged)
|
||||
|
||||
initialState:
|
||||
registers:
|
||||
R0: 15
|
||||
R1: 27
|
||||
|
||||
testCases:
|
||||
- init: {}
|
||||
expected:
|
||||
registers:
|
||||
R0: 15
|
||||
R1: 27
|
||||
R2: 42
|
||||
|
||||
hints:
|
||||
- "First operand of ADD is the destination, the next two are added"
|
||||
- "ADD R2, R0, R1 — result goes in R2"
|
||||
- "Answer: ADD R2, R0, R1 / XHLT"
|
||||
|
||||
starThresholds: [2, 3, 5]
|
||||
|
||||
starterCode: |
|
||||
; R0=15, R1=27
|
||||
; Compute R0 + R1 into R2
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: false
|
||||
@@ -0,0 +1,47 @@
|
||||
id: 4
|
||||
title: Multiply and Divide
|
||||
subtitle: Stronger arithmetic
|
||||
description: Learn the MUL and XDIV instructions
|
||||
|
||||
tutorial:
|
||||
- title: MUL — multiplication
|
||||
text: >
|
||||
MUL is also three operands:
|
||||
code: |
|
||||
MOV R0, #6
|
||||
MOV R1, #7
|
||||
MUL R2, R0, R1 ; R2 = 6 × 7 = 42
|
||||
- title: XDIV — integer division
|
||||
text: >
|
||||
XDIV does integer division (the fractional part is discarded):
|
||||
code: |
|
||||
MOV R0, #100
|
||||
MOV R1, #4
|
||||
XDIV R2, R0, R1 ; R2 = 100 ÷ 4 = 25
|
||||
|
||||
goal: Compute **6 × 7** into R0 and **100 ÷ 4** into R1
|
||||
|
||||
initialState: {}
|
||||
|
||||
testCases:
|
||||
- init: {}
|
||||
expected:
|
||||
registers:
|
||||
R0: 42
|
||||
R1: 25
|
||||
|
||||
hints:
|
||||
- "MOV the numbers into registers first, then MUL/XDIV"
|
||||
- "MUL R0, R2, R3 puts R2×R3 into R0"
|
||||
- "Answer: MOV R2, #6 / MOV R3, #7 / MUL R0, R2, R3 / MOV R2, #100 / MOV R3, #4 / XDIV R1, R2, R3 / XHLT"
|
||||
|
||||
starThresholds: [7, 9, 12]
|
||||
|
||||
starterCode: |
|
||||
; Compute 6×7 into R0
|
||||
; Compute 100÷4 into R1
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: false
|
||||
@@ -0,0 +1,58 @@
|
||||
id: 5
|
||||
title: Bitwise Magic
|
||||
subtitle: The secret life of 0s and 1s
|
||||
description: Learn the AND, ORR, EOR, and MVN instructions
|
||||
|
||||
tutorial:
|
||||
- title: The binary world
|
||||
text: >
|
||||
Computers store everything as **0**s and **1**s. 42 in binary is `00101010`.
|
||||
The state panel on the right shows the binary form of every register!
|
||||
- title: AND — both must be 1
|
||||
text: >
|
||||
AND compares bit by bit, returning 1 only when both bits are 1. Great for
|
||||
"extracting" specific bits:
|
||||
code: |
|
||||
; 11111111 (255)
|
||||
; AND 00001111 (15)
|
||||
; = 00001111 (15)
|
||||
AND R0, R0, #15
|
||||
- title: Other bitwise ops
|
||||
text: >
|
||||
**ORR** — 1 if either bit is 1 (OR)
|
||||
|
||||
**EOR** — 1 if the bits differ (XOR)
|
||||
|
||||
**MVN** — flip every bit (NOT)
|
||||
code: |
|
||||
ORR R0, R0, #240 ; set the upper 4 bits
|
||||
EOR R0, R0, #255 ; flip the lower 8 bits
|
||||
MVN R0, R0 ; flip every bit
|
||||
|
||||
goal: R0 = **255** (binary 11111111). Use AND to extract the **lower 4 bits** so R0 becomes **15**
|
||||
|
||||
initialState:
|
||||
registers:
|
||||
R0: 255
|
||||
|
||||
testCases:
|
||||
- init: {}
|
||||
expected:
|
||||
registers:
|
||||
R0: 15
|
||||
|
||||
hints:
|
||||
- "AND keeps the bits you want and clears the rest"
|
||||
- "The mask for the lower 4 bits is 15 (binary 00001111)"
|
||||
- "Answer: AND R0, R0, #15 / XHLT"
|
||||
|
||||
starThresholds: [2, 3, 5]
|
||||
|
||||
starterCode: |
|
||||
; R0 = 255 (binary 11111111)
|
||||
; Use AND to extract the lower 4 bits
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: false
|
||||
@@ -0,0 +1,54 @@
|
||||
id: 6
|
||||
title: Shift Operations
|
||||
subtitle: A dance of bits
|
||||
description: Learn the LSL and LSR instructions
|
||||
|
||||
tutorial:
|
||||
- title: LSL — Logical Shift Left
|
||||
text: >
|
||||
All bits move left, zeros fill in on the right. **Shifting left by 1 = multiplying by 2**.
|
||||
Shifting left by 3 = multiplying by 8:
|
||||
code: |
|
||||
; 5 = 00000101
|
||||
LSL R0, R0, #1 ; 00001010 = 10 (×2)
|
||||
LSL R0, R0, #1 ; 00010100 = 20 (×2)
|
||||
- title: LSR — Logical Shift Right
|
||||
text: >
|
||||
All bits move right, zeros fill in on the left. **Shifting right by 1 = dividing by 2**:
|
||||
code: |
|
||||
MOV R0, #40
|
||||
LSR R0, R0, #1 ; 20 (÷2)
|
||||
LSR R0, R0, #2 ; 5 (÷4)
|
||||
- title: A programmer's trick
|
||||
text: >
|
||||
On a real ARM CPU, shifts are far cheaper than multiply/divide!
|
||||
`LSL R0, R0, #3` beats `MUL R0, R0, #8`.
|
||||
|
||||
goal: R0 = **5**. Use **shift only** to make it **40** (40 = 5 × 8 = 5 × 2³)
|
||||
|
||||
initialState:
|
||||
registers:
|
||||
R0: 5
|
||||
|
||||
testCases:
|
||||
- init: {}
|
||||
expected:
|
||||
registers:
|
||||
R0: 40
|
||||
|
||||
hints:
|
||||
- "8 = 2³ — multiplying by 8 is shifting left by 3"
|
||||
- "LSL R0, R0, #3"
|
||||
- "It's just one instruction!"
|
||||
|
||||
starThresholds: [2, 3, 5]
|
||||
blockedOps: [MUL, XDIV]
|
||||
|
||||
starterCode: |
|
||||
; R0 = 5
|
||||
; Use LSL to turn R0 into 40 (no MUL allowed)
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: false
|
||||
@@ -0,0 +1,62 @@
|
||||
id: 7
|
||||
title: Memory Read and Write
|
||||
subtitle: Open up the bigger storage
|
||||
description: Learn the LDR and STR instructions
|
||||
|
||||
tutorial:
|
||||
- title: What is memory?
|
||||
text: >
|
||||
8 registers isn't a lot! **Memory** is like a row of 256 lockers, each with
|
||||
its own number (0-255).
|
||||
- title: LDR — load from memory
|
||||
text: >
|
||||
Put the address into a register, then use `LDR` to read from there:
|
||||
code: |
|
||||
MOV R1, #0 ; address = 0
|
||||
LDR R0, [R1] ; R0 = memory[0]
|
||||
- title: STR — store into memory
|
||||
text: >
|
||||
`STR` writes a register's value into memory:
|
||||
code: |
|
||||
MOV R1, #5 ; address = 5
|
||||
STR R0, [R1] ; memory[5] = R0
|
||||
- title: Offset addressing
|
||||
text: >
|
||||
You can also add an offset: `[R1, #4]` means address R1+4:
|
||||
code: |
|
||||
MOV R1, #0
|
||||
LDR R0, [R1, #0] ; memory[0]
|
||||
LDR R2, [R1, #1] ; memory[1]
|
||||
|
||||
goal: memory[0]=**10**, memory[1]=**20** — store their sum into **memory[2]**
|
||||
|
||||
initialState:
|
||||
memory:
|
||||
0: 10
|
||||
1: 20
|
||||
|
||||
testCases:
|
||||
- init: {}
|
||||
expected:
|
||||
memory:
|
||||
2: 30
|
||||
|
||||
hints:
|
||||
- "LDR the values into registers, add them, then STR the result back"
|
||||
- "MOV R3, #0 sets a base address; LDR R0, [R3, #0] reads the first value"
|
||||
- "Answer: MOV R3, #0 / LDR R0, [R3, #0] / LDR R1, [R3, #1] / ADD R2, R0, R1 / STR R2, [R3, #2] / XHLT"
|
||||
|
||||
starThresholds: [6, 8, 10]
|
||||
|
||||
starterCode: |
|
||||
; memory[0]=10, memory[1]=20
|
||||
; Compute the sum and store into memory[2]
|
||||
;
|
||||
; Tip: MOV an address into a register first
|
||||
; then use LDR/STR to read/write memory
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: true
|
||||
memoryRange: [0, 15]
|
||||
@@ -0,0 +1,79 @@
|
||||
id: 8
|
||||
title: Compare and Branch
|
||||
subtitle: Letting the program decide
|
||||
description: Learn CMP and conditional branch instructions
|
||||
|
||||
tutorial:
|
||||
- title: Up to now...
|
||||
text: >
|
||||
Programs ran top to bottom in order. With **branching**, the program can
|
||||
finally make decisions!
|
||||
- title: CMP — compare
|
||||
text: >
|
||||
`CMP` compares two values and remembers the result (the values themselves
|
||||
are unchanged):
|
||||
code: |
|
||||
CMP R0, #10 ; compare R0 with 10
|
||||
- title: Conditional branches
|
||||
text: >
|
||||
After comparing, use **B** (Branch) to jump:
|
||||
code: |
|
||||
BEQ label ; jump if Equal
|
||||
BNE label ; jump if Not Equal
|
||||
BGT label ; jump if Greater Than
|
||||
BLT label ; jump if Less Than
|
||||
B label ; unconditional jump
|
||||
- title: Labels
|
||||
text: >
|
||||
A **label** is a marker in your code that branches jump to. Add a colon after the name:
|
||||
code: |
|
||||
CMP R0, #10
|
||||
BGT big
|
||||
MOV R1, #0 ; R0 <= 10
|
||||
B done ; skip the next part
|
||||
big:
|
||||
MOV R1, #1 ; R0 > 10
|
||||
done:
|
||||
XHLT
|
||||
|
||||
goal: R0=**15**. If R0 > 10 then R1 = **1**; otherwise R1 = **0**
|
||||
|
||||
initialState:
|
||||
registers:
|
||||
R0: 15
|
||||
|
||||
testCases:
|
||||
- init:
|
||||
registers:
|
||||
R0: 15
|
||||
expected:
|
||||
registers:
|
||||
R1: 1
|
||||
- init:
|
||||
registers:
|
||||
R0: 5
|
||||
expected:
|
||||
registers:
|
||||
R1: 0
|
||||
- init:
|
||||
registers:
|
||||
R0: 10
|
||||
expected:
|
||||
registers:
|
||||
R1: 0
|
||||
|
||||
hints:
|
||||
- "Default R1 to 0, then compare R0 with 10"
|
||||
- "If R0 > 10, jump to a label that sets R1 to 1"
|
||||
- "Answer: MOV R1, #0 / CMP R0, #10 / BLE done / MOV R1, #1 / done: XHLT"
|
||||
|
||||
starThresholds: [5, 7, 9]
|
||||
|
||||
starterCode: |
|
||||
; If R0 > 10 then R1 = 1
|
||||
; Otherwise R1 = 0
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: false
|
||||
@@ -0,0 +1,52 @@
|
||||
id: 9
|
||||
title: Loops
|
||||
subtitle: The power of repetition
|
||||
description: Use branches to build a loop
|
||||
|
||||
tutorial:
|
||||
- title: What is a loop?
|
||||
text: >
|
||||
A loop runs the same code **over and over**. In assembly, a loop is just
|
||||
**branching back to a label**!
|
||||
- title: Loop structure
|
||||
text: >
|
||||
① initialize ② do work ③ update the counter ④ test + branch back:
|
||||
code: |
|
||||
MOV R4, #0 ; ① initialize
|
||||
loop: ; loop start
|
||||
ADD R4, R4, #1 ; ②③ counter += 1
|
||||
CMP R4, #5 ; ④ reached 5?
|
||||
BLE loop ; not yet, jump back
|
||||
XHLT
|
||||
- title: Watch out!
|
||||
text: >
|
||||
Forget to update the counter and you've made an **infinite loop**
|
||||
(don't worry — execution stops automatically after 10000 steps).
|
||||
|
||||
goal: Compute **1+2+3+...+10** into **R0** (the answer is 55)
|
||||
|
||||
initialState: {}
|
||||
|
||||
testCases:
|
||||
- init: {}
|
||||
expected:
|
||||
registers:
|
||||
R0: 55
|
||||
|
||||
hints:
|
||||
- "R0 accumulates the sum, R4 is the counter (1 to 10)"
|
||||
- "Loop body: ADD R0, R0, R4 / ADD R4, R4, #1 / CMP R4, #10 / BLE loop"
|
||||
- "Full: MOV R0, #0 / MOV R4, #1 / loop: ADD R0, R0, R4 / ADD R4, R4, #1 / CMP R4, #10 / BLE loop / XHLT"
|
||||
|
||||
starThresholds: [7, 9, 12]
|
||||
|
||||
starterCode: |
|
||||
; Compute 1+2+3+...+10
|
||||
; Result goes in R0
|
||||
;
|
||||
; Tip: use a register as the counter
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: false
|
||||
@@ -0,0 +1,81 @@
|
||||
id: 10
|
||||
title: Final Challenge
|
||||
subtitle: Find the maximum
|
||||
description: Bring all your skills together!
|
||||
|
||||
tutorial:
|
||||
- title: The last level!
|
||||
text: >
|
||||
You've learned registers, arithmetic, bitwise ops, memory, branches and loops.
|
||||
Now combine **all of it**!
|
||||
- title: The challenge
|
||||
text: >
|
||||
Memory addresses 0-4 hold five numbers. Find the **maximum value** and its
|
||||
**position**. You'll need: a loop + memory reads + compare-and-branch.
|
||||
- title: Approach
|
||||
text: |
|
||||
1. Assume the first number is the largest (R0 = memory[0], R1 = position 0)
|
||||
2. Loop through the remaining numbers
|
||||
3. If a larger one shows up, update both the max and its position
|
||||
4. Continue until all 5 numbers are checked
|
||||
code: |
|
||||
; Pseudocode:
|
||||
; R0 = max = mem[0]
|
||||
; R1 = maxIdx = 0
|
||||
; for R4 = 1 to 4:
|
||||
; R5 = mem[R4]
|
||||
; if R5 > R0: R0=R5, R1=R4
|
||||
|
||||
goal: memory[0..4] holds 5 numbers — store the **maximum** in **R0** and its **position** in **R1**
|
||||
|
||||
initialState:
|
||||
memory:
|
||||
0: 5
|
||||
1: 3
|
||||
2: 8
|
||||
3: 1
|
||||
4: 7
|
||||
|
||||
testCases:
|
||||
- init:
|
||||
memory:
|
||||
0: 5
|
||||
1: 3
|
||||
2: 8
|
||||
3: 1
|
||||
4: 7
|
||||
expected:
|
||||
registers:
|
||||
R0: 8
|
||||
R1: 2
|
||||
- init:
|
||||
memory:
|
||||
0: 1
|
||||
1: 9
|
||||
2: 4
|
||||
3: 9
|
||||
4: 2
|
||||
expected:
|
||||
registers:
|
||||
R0: 9
|
||||
R1: 1
|
||||
|
||||
hints:
|
||||
- "R0 = max, R1 = position, R4 = loop counter, R5 = current value"
|
||||
- "Use MOV R3, R4 / LDR R5, [R3] to read mem[R4]"
|
||||
- "Loop body: MOV R3, R4 / LDR R5, [R3] / CMP R5, R0 / BLE skip / MOV R0, R5 / MOV R1, R4 / skip: ADD R4, R4, #1 / CMP R4, #5 / BLT loop"
|
||||
|
||||
starThresholds: [12, 15, 20]
|
||||
|
||||
starterCode: |
|
||||
; memory[0..4] = [5, 3, 8, 1, 7]
|
||||
; Find the max into R0, the position into R1
|
||||
;
|
||||
; Tip: use R4 as the loop variable
|
||||
; use MOV + LDR to read memory
|
||||
|
||||
|
||||
XHLT
|
||||
|
||||
showMemory: true
|
||||
memoryRange: [0, 15]
|
||||
@@ -0,0 +1,375 @@
|
||||
const REGISTERS = ['R0','R1','R2','R3','R4','R5','R6','R7']
|
||||
const OPCODES = [
|
||||
'MOV','ADD','SUB','MUL','XDIV','XMOD',
|
||||
'AND','ORR','EOR','MVN','LSL','LSR',
|
||||
'CMP','B','BEQ','BNE','BGT','BLT','BGE','BLE',
|
||||
'LDR','STR','PUSH','POP','XOUT','XHLT','NOP',
|
||||
]
|
||||
const BRANCH_OPS = ['B','BEQ','BNE','BGT','BLT','BGE','BLE']
|
||||
const MAX_STEPS = 10000
|
||||
|
||||
function parseNumber(s) {
|
||||
s = s.trim()
|
||||
if (/^0x[0-9a-fA-F]+$/i.test(s)) return parseInt(s, 16)
|
||||
if (/^0b[01]+$/i.test(s)) return parseInt(s.slice(2), 2)
|
||||
if (/^-?\d+$/.test(s)) return parseInt(s, 10)
|
||||
return null
|
||||
}
|
||||
|
||||
function splitOperands(str) {
|
||||
const result = []; let cur = ''; let depth = 0
|
||||
for (const ch of str) {
|
||||
if (ch === '[' || ch === '{') depth++
|
||||
if (ch === ']' || ch === '}') depth--
|
||||
if (ch === ',' && depth === 0) { result.push(cur.trim()); cur = '' }
|
||||
else cur += ch
|
||||
}
|
||||
if (cur.trim()) result.push(cur.trim())
|
||||
return result
|
||||
}
|
||||
|
||||
function parseOperand(s) {
|
||||
s = s.trim()
|
||||
const upper = s.toUpperCase()
|
||||
if (REGISTERS.includes(upper)) return { type: 'reg', value: upper }
|
||||
|
||||
// Immediate: #42, #0xFF, #0b101
|
||||
if (s.startsWith('#')) {
|
||||
const n = parseNumber(s.slice(1))
|
||||
if (n !== null) return { type: 'imm', value: n }
|
||||
}
|
||||
|
||||
// Memory: [R0], [R0, #4], [#addr]
|
||||
const memMatch = s.match(/^\[([^\],]+)(?:,\s*(.+))?\]$/)
|
||||
if (memMatch) {
|
||||
const base = memMatch[1].trim()
|
||||
const baseUp = base.toUpperCase()
|
||||
if (REGISTERS.includes(baseUp)) {
|
||||
let offset = 0
|
||||
if (memMatch[2]) {
|
||||
let off = memMatch[2].trim()
|
||||
if (off.startsWith('#')) off = off.slice(1)
|
||||
offset = parseNumber(off) || 0
|
||||
}
|
||||
return { type: 'mem', base: baseUp, offset }
|
||||
}
|
||||
// [#addr] direct
|
||||
let addr = base.startsWith('#') ? base.slice(1) : base
|
||||
const n = parseNumber(addr)
|
||||
if (n !== null) return { type: 'mem_direct', addr: n & 0xFF }
|
||||
}
|
||||
|
||||
// {R0} for PUSH/POP
|
||||
const braceMatch = s.match(/^\{(.+)\}$/)
|
||||
if (braceMatch) {
|
||||
const inner = braceMatch[1].trim().toUpperCase()
|
||||
if (REGISTERS.includes(inner)) return { type: 'reg', value: inner }
|
||||
}
|
||||
|
||||
// Label
|
||||
if (/^[a-zA-Z_]\w*$/.test(s)) return { type: 'label', value: s.toLowerCase() }
|
||||
|
||||
// Bare number (lenient)
|
||||
const n = parseNumber(s)
|
||||
if (n !== null) return { type: 'imm', value: n }
|
||||
|
||||
return { type: 'unknown', raw: s }
|
||||
}
|
||||
|
||||
export function parse(code) {
|
||||
const lines = code.split('\n')
|
||||
const instructions = []
|
||||
const labels = {}
|
||||
const errors = []
|
||||
|
||||
// Pass 1: find labels
|
||||
let idx = 0
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]
|
||||
const ci = line.indexOf(';'); if (ci >= 0) line = line.slice(0, ci)
|
||||
line = line.trim(); if (!line) continue
|
||||
const lm = line.match(/^([a-zA-Z_]\w*)\s*:(.*)$/)
|
||||
if (lm) { labels[lm[1].toLowerCase()] = idx; line = lm[2].trim(); if (!line) continue }
|
||||
idx++
|
||||
}
|
||||
|
||||
// Pass 2: parse instructions
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i]
|
||||
const ci = line.indexOf(';'); if (ci >= 0) line = line.slice(0, ci)
|
||||
line = line.trim(); if (!line) continue
|
||||
const lm = line.match(/^([a-zA-Z_]\w*)\s*:(.*)$/)
|
||||
if (lm) { line = lm[2].trim(); if (!line) continue }
|
||||
|
||||
const parts = line.match(/^(\w+)(?:\s+(.*))?$/)
|
||||
if (!parts) { errors.push({ line: i, msg: `Syntax error: ${lines[i].trim()}` }); continue }
|
||||
|
||||
const opcode = parts[1].toUpperCase()
|
||||
if (!OPCODES.includes(opcode)) { errors.push({ line: i, msg: `Unknown instruction: ${parts[1]}` }); continue }
|
||||
|
||||
let operands = []
|
||||
if (parts[2]) {
|
||||
operands = splitOperands(parts[2]).map(parseOperand)
|
||||
const bad = operands.find(o => o.type === 'unknown')
|
||||
if (bad) { errors.push({ line: i, msg: `Unrecognized operand: ${bad.raw}` }); continue }
|
||||
}
|
||||
instructions.push({ opcode, operands, srcLine: i })
|
||||
}
|
||||
return { instructions, labels, errors }
|
||||
}
|
||||
|
||||
export function createVM() {
|
||||
const state = {
|
||||
registers: { R0:0,R1:0,R2:0,R3:0,R4:0,R5:0,R6:0,R7:0 },
|
||||
flags: { zero:false, carry:false, negative:false, overflow:false },
|
||||
memory: new Array(256).fill(0),
|
||||
stack: [],
|
||||
pc: 0, halted: false, error: null,
|
||||
output: [], input: [],
|
||||
stepCount: 0, instructions: [], labels: {},
|
||||
cmpA: 0, cmpB: 0,
|
||||
}
|
||||
|
||||
function getVal(op) {
|
||||
switch (op.type) {
|
||||
case 'reg': return state.registers[op.value]
|
||||
case 'imm': return op.value
|
||||
case 'mem': return state.memory[(state.registers[op.base] + (op.offset||0)) & 0xFF]
|
||||
case 'mem_direct': return state.memory[op.addr]
|
||||
default: throw new Error(`Cannot read: ${JSON.stringify(op)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function setReg(op, val, changes) {
|
||||
val = ((val % 65536) + 65536) % 65536
|
||||
if (op.type !== 'reg') throw new Error('Destination must be a register')
|
||||
const old = state.registers[op.value]
|
||||
state.registers[op.value] = val
|
||||
changes.push({ type: 'reg', name: op.value, old, val })
|
||||
}
|
||||
|
||||
function setMem(addr, val, changes) {
|
||||
addr = addr & 0xFF
|
||||
val = ((val % 65536) + 65536) % 65536
|
||||
const old = state.memory[addr]
|
||||
state.memory[addr] = val
|
||||
changes.push({ type: 'mem', addr, old, val })
|
||||
}
|
||||
|
||||
function updateFlags(val) {
|
||||
val = ((val % 65536) + 65536) % 65536
|
||||
state.flags.zero = val === 0
|
||||
state.flags.negative = (val & 0x8000) !== 0
|
||||
}
|
||||
|
||||
function condMet(op) {
|
||||
const a = state.cmpA, b = state.cmpB
|
||||
switch (op) {
|
||||
case 'B': return true
|
||||
case 'BEQ': return a === b
|
||||
case 'BNE': return a !== b
|
||||
case 'BGT': return a > b
|
||||
case 'BLT': return a < b
|
||||
case 'BGE': return a >= b
|
||||
case 'BLE': return a <= b
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// For 2-or-3 operand arithmetic: if 3 ops → Rd = op(Rn, Rm); if 2 ops → Rd = op(Rd, Rm)
|
||||
function arith3(ops, fn) {
|
||||
if (ops.length >= 3) return fn(getVal(ops[1]), getVal(ops[2]))
|
||||
return fn(getVal(ops[0]), getVal(ops[1]))
|
||||
}
|
||||
|
||||
function step() {
|
||||
if (state.halted || state.error) return null
|
||||
if (state.pc >= state.instructions.length) { state.halted = true; return null }
|
||||
if (state.stepCount >= MAX_STEPS) {
|
||||
state.error = 'Exceeded maximum step count (10000) — likely an infinite loop!'; return null
|
||||
}
|
||||
|
||||
const instr = state.instructions[state.pc]
|
||||
const { opcode, operands: ops } = instr
|
||||
const changes = []
|
||||
let jumped = false
|
||||
state.stepCount++
|
||||
|
||||
try {
|
||||
switch (opcode) {
|
||||
case 'NOP': break
|
||||
case 'XHLT': state.halted = true; break
|
||||
|
||||
case 'MOV':
|
||||
setReg(ops[0], getVal(ops[1]), changes)
|
||||
break
|
||||
|
||||
case 'ADD': { const r = arith3(ops, (a,b) => a+b); state.flags.carry = r > 0xFFFF; setReg(ops[0], r, changes); updateFlags(r); break }
|
||||
case 'SUB': { const r = arith3(ops, (a,b) => a-b); state.flags.carry = r < 0; setReg(ops[0], r, changes); updateFlags(r); break }
|
||||
case 'MUL': { const r = arith3(ops, (a,b) => a*b); state.flags.carry = r > 0xFFFF; setReg(ops[0], r, changes); updateFlags(r); break }
|
||||
case 'XDIV': {
|
||||
const r = arith3(ops, (a,b) => { if(b===0) throw new Error('Division by zero!'); return Math.floor(a/b) })
|
||||
setReg(ops[0], r, changes); updateFlags(r); break
|
||||
}
|
||||
case 'XMOD': {
|
||||
const r = arith3(ops, (a,b) => { if(b===0) throw new Error('Division by zero!'); return a%b })
|
||||
setReg(ops[0], r, changes); updateFlags(r); break
|
||||
}
|
||||
|
||||
case 'AND': { const r = arith3(ops, (a,b) => a&b); setReg(ops[0], r, changes); updateFlags(r); break }
|
||||
case 'ORR': { const r = arith3(ops, (a,b) => a|b); setReg(ops[0], r, changes); updateFlags(r); break }
|
||||
case 'EOR': { const r = arith3(ops, (a,b) => a^b); setReg(ops[0], r, changes); updateFlags(r); break }
|
||||
case 'MVN': { const r = (~getVal(ops[1])) & 0xFFFF; setReg(ops[0], r, changes); updateFlags(r); break }
|
||||
case 'LSL': { const r = arith3(ops, (a,b) => (a << b) & 0xFFFF); setReg(ops[0], r, changes); updateFlags(r); break }
|
||||
case 'LSR': { const r = arith3(ops, (a,b) => a >>> b); setReg(ops[0], r, changes); updateFlags(r); break }
|
||||
|
||||
case 'CMP': {
|
||||
state.cmpA = getVal(ops[0]); state.cmpB = getVal(ops[1])
|
||||
const d = state.cmpA - state.cmpB
|
||||
state.flags.zero = d === 0; state.flags.negative = d < 0; state.flags.carry = state.cmpA < state.cmpB
|
||||
break
|
||||
}
|
||||
|
||||
case 'B': case 'BEQ': case 'BNE': case 'BGT': case 'BLT': case 'BGE': case 'BLE': {
|
||||
if (condMet(opcode)) {
|
||||
const lbl = ops[0].value
|
||||
if (state.labels[lbl] === undefined) throw new Error(`Unknown label: ${lbl}`)
|
||||
state.pc = state.labels[lbl]; jumped = true
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'LDR': {
|
||||
const memOp = ops[1]
|
||||
let addr
|
||||
if (memOp.type === 'mem') addr = (state.registers[memOp.base] + (memOp.offset||0)) & 0xFF
|
||||
else if (memOp.type === 'mem_direct') addr = memOp.addr
|
||||
else throw new Error('LDR needs a memory address, e.g. [R0] or [R0, #4]')
|
||||
const v = state.memory[addr]
|
||||
setReg(ops[0], v, changes)
|
||||
changes.push({ type: 'mem_read', addr })
|
||||
break
|
||||
}
|
||||
case 'STR': {
|
||||
const memOp = ops[1]
|
||||
let addr
|
||||
if (memOp.type === 'mem') addr = (state.registers[memOp.base] + (memOp.offset||0)) & 0xFF
|
||||
else if (memOp.type === 'mem_direct') addr = memOp.addr
|
||||
else throw new Error('STR needs a memory address, e.g. [R0] or [R0, #4]')
|
||||
setMem(addr, getVal(ops[0]), changes)
|
||||
break
|
||||
}
|
||||
|
||||
case 'PUSH': {
|
||||
if (state.stack.length >= 64) throw new Error('Stack overflow!')
|
||||
const v = getVal(ops[0])
|
||||
state.stack.push(v)
|
||||
changes.push({ type: 'stack_push', val: v })
|
||||
break
|
||||
}
|
||||
case 'POP': {
|
||||
if (state.stack.length === 0) throw new Error('Stack is empty!')
|
||||
setReg(ops[0], state.stack.pop(), changes)
|
||||
changes.push({ type: 'stack_pop' })
|
||||
break
|
||||
}
|
||||
|
||||
case 'XOUT': {
|
||||
const v = getVal(ops[0])
|
||||
state.output.push(v)
|
||||
changes.push({ type: 'output', val: v })
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
state.error = `Line ${instr.srcLine + 1}: ${e.message}`
|
||||
return { pc: state.pc, instr, changes, error: state.error }
|
||||
}
|
||||
|
||||
if (!jumped) state.pc++
|
||||
return { pc: state.pc, instr, changes, srcLine: instr.srcLine }
|
||||
}
|
||||
|
||||
function loadProgram(code) {
|
||||
const result = parse(code)
|
||||
if (result.errors.length > 0) { state.error = result.errors[0].msg; return result }
|
||||
state.instructions = result.instructions
|
||||
state.labels = result.labels
|
||||
state.pc = 0; state.halted = false; state.error = null; state.stepCount = 0
|
||||
state.output = []; state.stack = []
|
||||
state.flags = { zero:false, carry:false, negative:false, overflow:false }
|
||||
state.cmpA = 0; state.cmpB = 0
|
||||
return result
|
||||
}
|
||||
|
||||
function run() {
|
||||
while (!state.halted && !state.error && state.stepCount < MAX_STEPS) step()
|
||||
if (state.stepCount >= MAX_STEPS && !state.halted && !state.error)
|
||||
state.error = 'Exceeded maximum step count (10000) — likely an infinite loop!'
|
||||
}
|
||||
|
||||
function reset() {
|
||||
state.registers = { R0:0,R1:0,R2:0,R3:0,R4:0,R5:0,R6:0,R7:0 }
|
||||
state.flags = { zero:false, carry:false, negative:false, overflow:false }
|
||||
state.memory = new Array(256).fill(0)
|
||||
state.stack = []; state.pc = 0; state.halted = false; state.error = null
|
||||
state.output = []; state.input = []; state.stepCount = 0
|
||||
state.cmpA = 0; state.cmpB = 0
|
||||
}
|
||||
|
||||
return { state, step, run, loadProgram, reset }
|
||||
}
|
||||
|
||||
export function countInstructions(code) {
|
||||
let c = 0
|
||||
for (const line of code.split('\n')) {
|
||||
let l = line; const ci = l.indexOf(';'); if (ci >= 0) l = l.slice(0, ci); l = l.trim()
|
||||
if (!l) continue
|
||||
const lm = l.match(/^[a-zA-Z_]\w*\s*:(.*)$/); if (lm) { l = lm[1].trim(); if (!l) continue }
|
||||
c++
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
export function validateLevel(level, vm) {
|
||||
const s = vm.state
|
||||
if (s.error) return { passed: false, msg: s.error }
|
||||
|
||||
for (const tc of level.testCases) {
|
||||
vm.reset()
|
||||
if (level.initialState) {
|
||||
if (level.initialState.registers) Object.assign(vm.state.registers, level.initialState.registers)
|
||||
if (level.initialState.memory) for (const [a,v] of Object.entries(level.initialState.memory)) vm.state.memory[+a] = v
|
||||
if (level.initialState.input) vm.state.input = [...level.initialState.input]
|
||||
}
|
||||
if (tc.init) {
|
||||
if (tc.init.registers) Object.assign(vm.state.registers, tc.init.registers)
|
||||
if (tc.init.memory) for (const [a,v] of Object.entries(tc.init.memory)) vm.state.memory[+a] = v
|
||||
if (tc.init.input) vm.state.input = [...tc.init.input]
|
||||
}
|
||||
|
||||
vm.run()
|
||||
if (vm.state.error) return { passed: false, msg: vm.state.error }
|
||||
|
||||
const exp = tc.expected
|
||||
if (exp.registers) {
|
||||
for (const [r,v] of Object.entries(exp.registers)) {
|
||||
if (vm.state.registers[r] !== v)
|
||||
return { passed: false, msg: `${r} should be ${v}, but got ${vm.state.registers[r]}` }
|
||||
}
|
||||
}
|
||||
if (exp.memory) {
|
||||
for (const [a,v] of Object.entries(exp.memory)) {
|
||||
if (vm.state.memory[+a] !== v)
|
||||
return { passed: false, msg: `memory[${a}] should be ${v}, but got ${vm.state.memory[+a]}` }
|
||||
}
|
||||
}
|
||||
if (exp.output) {
|
||||
for (let i = 0; i < exp.output.length; i++) {
|
||||
if (vm.state.output[i] !== exp.output[i])
|
||||
return { passed: false, msg: `Output #${i+1} should be ${exp.output[i]}, but got ${vm.state.output[i] ?? 'none'}` }
|
||||
}
|
||||
}
|
||||
}
|
||||
return { passed: true, msg: 'Passed!' }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router/index.js'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{ path: '/', name: 'welcome', component: () => import('../views/WelcomeView.vue') },
|
||||
{ path: '/levels', name: 'levels', component: () => import('../views/LevelSelectView.vue') },
|
||||
{ path: '/level/:id', name: 'level', component: () => import('../views/LevelView.vue'), props: true },
|
||||
]
|
||||
|
||||
export default createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useGameStore = defineStore('game', {
|
||||
state: () => ({
|
||||
playerName: localStorage.getItem('asm_playerName') || '',
|
||||
playerId: parseInt(localStorage.getItem('asm_playerId')) || null,
|
||||
progress: JSON.parse(localStorage.getItem('asm_progress') || '{}'),
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isLoggedIn: (state) => !!state.playerName && !!state.playerId,
|
||||
totalStars: (state) => Object.values(state.progress).reduce((s, p) => s + (p.stars || 0), 0),
|
||||
levelsCompleted: (state) => Object.values(state.progress).filter(p => p.completed).length,
|
||||
isLevelUnlocked() {
|
||||
return (levelId) => {
|
||||
if (levelId === 1) return true
|
||||
return !!this.progress[levelId - 1]?.completed
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
async login(name) {
|
||||
try {
|
||||
const res = await fetch('/api/players', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
const data = await res.json()
|
||||
this.playerName = data.name
|
||||
this.playerId = data.id
|
||||
if (data.progress) {
|
||||
this.progress = { ...this.progress, ...data.progress }
|
||||
}
|
||||
this._persist()
|
||||
return true
|
||||
} catch {
|
||||
// offline mode - just save locally
|
||||
this.playerName = name
|
||||
this.playerId = Date.now()
|
||||
this._persist()
|
||||
return true
|
||||
}
|
||||
},
|
||||
|
||||
async saveProgress(levelId, stars, code) {
|
||||
const existing = this.progress[levelId]
|
||||
const bestStars = Math.max(stars, existing?.stars || 0)
|
||||
this.progress[levelId] = { completed: true, stars: bestStars, code }
|
||||
this._persist()
|
||||
|
||||
try {
|
||||
await fetch('/api/progress', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
player_id: this.playerId,
|
||||
level_id: levelId,
|
||||
stars: bestStars,
|
||||
code,
|
||||
}),
|
||||
})
|
||||
} catch {
|
||||
// ok, saved locally
|
||||
}
|
||||
},
|
||||
|
||||
logout() {
|
||||
this.playerName = ''
|
||||
this.playerId = null
|
||||
this.progress = {}
|
||||
localStorage.removeItem('asm_playerName')
|
||||
localStorage.removeItem('asm_playerId')
|
||||
localStorage.removeItem('asm_progress')
|
||||
},
|
||||
|
||||
_persist() {
|
||||
localStorage.setItem('asm_playerName', this.playerName)
|
||||
localStorage.setItem('asm_playerId', String(this.playerId))
|
||||
localStorage.setItem('asm_progress', JSON.stringify(this.progress))
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div class="level-select">
|
||||
<header>
|
||||
<router-link to="/" class="back">← Home</router-link>
|
||||
<div class="player-info">
|
||||
<span class="player-name">{{ store.playerName }}</span>
|
||||
<span class="total-stars">{{ store.totalStars }} ⭐</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<h1>Select Level</h1>
|
||||
<p class="desc">Complete a level to unlock the next one. Up to 3 stars per level.</p>
|
||||
|
||||
<div class="levels-grid">
|
||||
<div
|
||||
v-for="level in levels"
|
||||
:key="level.id"
|
||||
class="level-card"
|
||||
:class="{ locked: !unlocked(level.id), completed: !!store.progress[level.id]?.completed }"
|
||||
@click="go(level)"
|
||||
>
|
||||
<div class="level-num">{{ level.id }}</div>
|
||||
<h3>{{ level.title }}</h3>
|
||||
<p class="card-subtitle">{{ level.subtitle }}</p>
|
||||
<p class="card-desc">{{ level.description }}</p>
|
||||
<div class="stars">
|
||||
<span v-for="s in 3" :key="s" :class="{ earned: s <= (store.progress[level.id]?.stars || 0) }">★</span>
|
||||
</div>
|
||||
<div v-if="!unlocked(level.id)" class="lock-overlay">🔒</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGameStore } from '../stores/game.js'
|
||||
import { levels } from '../lib/levels.js'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useGameStore()
|
||||
if (!store.isLoggedIn) router.replace('/')
|
||||
|
||||
const unlocked = (id) => store.isLevelUnlocked(id)
|
||||
|
||||
function go(level) {
|
||||
if (unlocked(level.id)) router.push(`/level/${level.id}`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.level-select { max-width: 960px; margin: 0 auto; padding: 24px; }
|
||||
header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 32px; }
|
||||
.back { color: var(--text-secondary); font-size: 14px; }
|
||||
.player-info { display: flex; gap: 16px; align-items: center; }
|
||||
.player-name { color: var(--accent-cyan); font-weight: 600; }
|
||||
.total-stars { color: var(--accent-yellow); }
|
||||
h1 { font-size: 28px; margin-bottom: 8px; text-align: center; }
|
||||
.desc { text-align: center; color: var(--text-secondary); margin-bottom: 36px; }
|
||||
|
||||
.levels-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.level-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.level-card:hover:not(.locked) {
|
||||
border-color: var(--accent-blue);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(59,130,246,0.12);
|
||||
}
|
||||
.level-card.completed { border-color: rgba(16,185,129,0.4); }
|
||||
.level-card.locked { opacity: 0.45; cursor: not-allowed; }
|
||||
.level-num {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 52px;
|
||||
font-weight: 700;
|
||||
color: var(--border);
|
||||
line-height: 1;
|
||||
}
|
||||
.level-icon { font-size: 32px; margin-bottom: 10px; }
|
||||
.level-card h3 { font-size: 18px; margin-bottom: 4px; }
|
||||
.card-subtitle { font-size: 13px; color: var(--accent-cyan); margin-bottom: 8px; }
|
||||
.card-desc { font-size: 13px; color: var(--text-secondary); margin-bottom: 16px; line-height: 1.4; }
|
||||
.stars { display: flex; gap: 4px; font-size: 20px; }
|
||||
.stars span { color: var(--border); transition: color 0.3s; }
|
||||
.stars span.earned { color: var(--accent-yellow); }
|
||||
.lock-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
background: rgba(10,14,26,0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div class="level-page">
|
||||
<header class="level-header">
|
||||
<router-link to="/levels" class="back-link">← Back</router-link>
|
||||
<div class="hdr-center">
|
||||
<span class="level-badge">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'">▶ Run</button>
|
||||
<button class="cb step" @click="stepCode" :disabled="mode === 'auto'">
|
||||
→{{ mode === 'step' ? ' Next' : ' Step' }}
|
||||
</button>
|
||||
<button v-if="mode === 'edit'" class="cb auto" @click="autoStep">⏩ Animate</button>
|
||||
<button v-if="mode !== 'edit'" class="cb reset" @click="resetVM">↺ Reset</button>
|
||||
</div>
|
||||
<div v-if="mode === 'auto'" class="speed">
|
||||
<label>Speed</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">
|
||||
💡 Hint ({{ 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 = `This level doesn't allow ${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 = 'Runtime error'
|
||||
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 = 'Stepping...'
|
||||
}
|
||||
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 = 'Animating...'; 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(); const lp = tv.loadProgram(code.value)
|
||||
if (lp.errors && lp.errors.length > 0) {
|
||||
vmErr.value = lp.errors[0].msg; statusMsg.value = 'Not passing yet — try again!'
|
||||
return
|
||||
}
|
||||
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 = 'Level complete!'
|
||||
} else {
|
||||
vmErr.value = r.msg; statusMsg.value = 'Not passing yet — try again!'
|
||||
}
|
||||
}
|
||||
|
||||
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: 1fr 1fr 1fr; 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>
|
||||
@@ -0,0 +1,158 @@
|
||||
<template>
|
||||
<div class="welcome">
|
||||
<div class="binary-rain" ref="rainEl"></div>
|
||||
<div class="welcome-card">
|
||||
<div class="logo">
|
||||
<span class="logo-icon">⚡</span>
|
||||
<h1>Simple ASM</h1>
|
||||
<p class="subtitle">Assembly Adventure</p>
|
||||
</div>
|
||||
<p class="intro">Ready to explore how computers think?<br>Enter your name to begin the adventure!</p>
|
||||
<form @submit.prevent="start" v-if="!store.isLoggedIn">
|
||||
<input v-model="name" type="text" placeholder="Enter your name..." autofocus maxlength="20" class="name-input" />
|
||||
<button type="submit" class="start-btn" :disabled="!name.trim()">Start Adventure →</button>
|
||||
</form>
|
||||
<div v-else class="returning">
|
||||
<p>Welcome back, <strong>{{ store.playerName }}</strong>!</p>
|
||||
<p class="stat-line">You have <span class="star-count">{{ store.totalStars }}</span> stars</p>
|
||||
<router-link to="/levels" class="continue-btn">Continue →</router-link>
|
||||
<button class="logout-btn" @click="store.logout()">Switch name</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGameStore } from '../stores/game.js'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useGameStore()
|
||||
const name = ref('')
|
||||
const rainEl = ref(null)
|
||||
|
||||
async function start() {
|
||||
const n = name.value.trim()
|
||||
if (!n) return
|
||||
await store.login(n)
|
||||
router.push('/levels')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!rainEl.value) return
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const col = document.createElement('div')
|
||||
col.className = 'rain-col'
|
||||
col.style.left = `${(i / 25) * 100 + Math.random() * 4}%`
|
||||
col.style.animationDuration = `${5 + Math.random() * 8}s`
|
||||
col.style.animationDelay = `${-Math.random() * 10}s`
|
||||
col.style.opacity = String(0.08 + Math.random() * 0.12)
|
||||
col.textContent = Array.from({ length: 50 }, () => Math.random() > 0.5 ? '1' : '0').join('\n')
|
||||
rainEl.value.appendChild(col)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.welcome {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.binary-rain { position: absolute; inset: 0; pointer-events: none; }
|
||||
.rain-col {
|
||||
position: absolute;
|
||||
top: -60%;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
color: var(--accent-blue);
|
||||
white-space: pre;
|
||||
animation: rain linear infinite;
|
||||
user-select: none;
|
||||
}
|
||||
@keyframes rain {
|
||||
from { transform: translateY(-40%); }
|
||||
to { transform: translateY(110vh); }
|
||||
}
|
||||
.welcome-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 48px 40px;
|
||||
max-width: 460px;
|
||||
width: 92%;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 80px rgba(59,130,246,0.08);
|
||||
}
|
||||
.logo { margin-bottom: 20px; }
|
||||
.logo-icon { font-size: 48px; display: block; margin-bottom: 4px; }
|
||||
.logo h1 {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--accent-cyan), var(--accent-blue));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.subtitle { font-size: 16px; color: var(--text-secondary); margin-top: 4px; }
|
||||
.intro { color: var(--text-secondary); line-height: 1.6; margin-bottom: 28px; font-size: 15px; }
|
||||
.name-input {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
background: var(--bg-surface);
|
||||
border: 2px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-primary);
|
||||
font-size: 16px;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.name-input:focus { border-color: var(--accent-blue); box-shadow: 0 0 0 3px rgba(59,130,246,0.2); }
|
||||
.name-input::placeholder { color: var(--text-muted); }
|
||||
.start-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.start-btn:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(59,130,246,0.4); }
|
||||
.start-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.returning { margin-top: 8px; }
|
||||
.returning p { color: var(--text-secondary); margin-bottom: 8px; }
|
||||
.returning strong { color: var(--accent-cyan); }
|
||||
.stat-line { margin-bottom: 20px !important; }
|
||||
.star-count { color: var(--accent-yellow); font-weight: 700; font-size: 18px; }
|
||||
.continue-btn {
|
||||
display: inline-block;
|
||||
padding: 14px 32px;
|
||||
background: linear-gradient(135deg, var(--accent-blue), var(--accent-cyan));
|
||||
border-radius: var(--radius);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
transition: all 0.3s;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.continue-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(59,130,246,0.4); text-decoration: none; }
|
||||
.logout-btn {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
padding: 4px 12px;
|
||||
}
|
||||
.logout-btn:hover { color: var(--text-secondary); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user