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,51 @@
|
||||
name: deploy simpleasm
|
||||
# asm.famzheng.me — 汇编教学小游戏。host shell runner(fam 用户)。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'apps/simpleasm/**'
|
||||
- 'crates/cube-core/**'
|
||||
- 'Cargo.toml'
|
||||
- 'Cargo.lock'
|
||||
- '.gitea/workflows/deploy-simpleasm.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
APP: simpleasm
|
||||
IMAGE: registry.famzheng.me/mochi/simpleasm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve image tag
|
||||
id: tag
|
||||
run: |
|
||||
echo "sha=$(git rev-parse --short=12 HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build rust (musl static)
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo build --release --target x86_64-unknown-linux-musl -p "$APP"
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
cd "apps/$APP/frontend"
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Build & push image
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
echo "$REGISTRY_TOKEN" | docker login registry.famzheng.me -u mochi --password-stdin
|
||||
docker build -f "apps/$APP/Dockerfile" -t "$IMAGE:${{ steps.tag.outputs.sha }}" .
|
||||
docker push "$IMAGE:${{ steps.tag.outputs.sha }}"
|
||||
|
||||
- name: Roll out to k3s
|
||||
run: |
|
||||
kubectl -n "cube-$APP" set image "deploy/$APP" "$APP=$IMAGE:${{ steps.tag.outputs.sha }}"
|
||||
kubectl -n "cube-$APP" rollout status "deploy/$APP" --timeout=120s
|
||||
Generated
+141
@@ -2,6 +2,18 @@
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
@@ -95,6 +107,16 @@ version = "1.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.61"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -130,6 +152,24 @@ dependencies = [
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
|
||||
|
||||
[[package]]
|
||||
name = "fallible-streaming-iterator"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
@@ -178,6 +218,24 @@ dependencies = [
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashlink"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
|
||||
dependencies = [
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -282,6 +340,17 @@ version = "0.2.186"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libsqlite3-sys"
|
||||
version = "0.30.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -395,6 +464,12 @@ version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -439,6 +514,20 @@ version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rusqlite"
|
||||
version = "0.32.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"fallible-iterator",
|
||||
"fallible-streaming-iterator",
|
||||
"hashlink",
|
||||
"libsqlite3-sys",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.22"
|
||||
@@ -464,6 +553,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -531,6 +621,12 @@ dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -541,6 +637,19 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simpleasm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"cube-core",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -777,6 +886,18 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
@@ -798,6 +919,26 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -3,6 +3,7 @@ resolver = "2"
|
||||
members = [
|
||||
"crates/cube-core",
|
||||
"apps/cube",
|
||||
"apps/simpleasm",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -17,6 +18,9 @@ tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
|
||||
@@ -33,9 +33,9 @@ export const apps: App[] = [
|
||||
{
|
||||
slug: 'simpleasm',
|
||||
name: 'simpleasm',
|
||||
description: '汇编教学/玩具。从 oci 迁移中(原 asm.oci.euphon.net)。',
|
||||
url: 'https://simpleasm.famzheng.me',
|
||||
status: 'pending',
|
||||
description: '汇编教学小游戏。',
|
||||
url: 'https://asm.famzheng.me',
|
||||
status: 'live',
|
||||
},
|
||||
{
|
||||
slug: 'guitar',
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "simpleasm"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
description = "asm.famzheng.me — 汇编教学小游戏,玩家进度持久化在 sqlite"
|
||||
|
||||
[dependencies]
|
||||
cube-core = { path = "../../crates/cube-core" }
|
||||
axum = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
rusqlite = { workspace = true }
|
||||
@@ -0,0 +1,8 @@
|
||||
# simpleasm — asm.famzheng.me
|
||||
# Build context = repo root(同 cube),所以路径都是 apps/simpleasm/...
|
||||
# Rust + frontend 都在 host 上 build,容器只是拼装。
|
||||
FROM scratch
|
||||
COPY target/x86_64-unknown-linux-musl/release/simpleasm /simpleasm
|
||||
COPY apps/simpleasm/frontend/dist /dist
|
||||
EXPOSE 8080
|
||||
ENTRYPOINT ["/simpleasm"]
|
||||
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Simple ASM - Assembly Adventure</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+1214
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "simpleasm",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modyfi/vite-plugin-yaml": "^1.1.1",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import yaml from '@modyfi/vite-plugin-yaml'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), yaml()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:8080'
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: simpleasm
|
||||
namespace: cube-simpleasm
|
||||
labels:
|
||||
app: simpleasm
|
||||
spec:
|
||||
replicas: 1
|
||||
strategy:
|
||||
# PVC 是 RWO,rolling 上线时新旧 pod 抢 PVC 会卡住,直接 Recreate
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: simpleasm
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: simpleasm
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: registry-creds
|
||||
containers:
|
||||
- name: simpleasm
|
||||
image: registry.famzheng.me/mochi/simpleasm:latest
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 8080
|
||||
name: http
|
||||
env:
|
||||
- name: DB_PATH
|
||||
value: /data/app.db
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 1
|
||||
periodSeconds: 5
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 15
|
||||
resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 16Mi
|
||||
limits:
|
||||
cpu: 200m
|
||||
memory: 64Mi
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: data
|
||||
persistentVolumeClaim:
|
||||
claimName: simpleasm-data
|
||||
@@ -0,0 +1,18 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: simpleasm
|
||||
namespace: cube-simpleasm
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
rules:
|
||||
- host: asm.famzheng.me
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: simpleasm
|
||||
port:
|
||||
number: 80
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: cube-simpleasm
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: simpleasm-data
|
||||
namespace: cube-simpleasm
|
||||
spec:
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
resources:
|
||||
requests:
|
||||
storage: 1Gi
|
||||
# storageClassName 留空 → 走 k3s 默认 local-path(hostPath,单节点足够)
|
||||
@@ -0,0 +1,12 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: simpleasm
|
||||
namespace: cube-simpleasm
|
||||
spec:
|
||||
selector:
|
||||
app: simpleasm
|
||||
ports:
|
||||
- name: http
|
||||
port: 80
|
||||
targetPort: 8080
|
||||
@@ -0,0 +1,214 @@
|
||||
//! asm.famzheng.me — 汇编教学小游戏。
|
||||
//!
|
||||
//! 4 个 endpoint:
|
||||
//! - `GET /api/health` 前端 ping 用
|
||||
//! - `POST /api/players` 按 name upsert,返回 id + 已有进度 map
|
||||
//! - `POST /api/progress` 单关卡 upsert(stars 只能增加,code 覆盖)
|
||||
//! - `GET /api/leaderboard` top 50(按 total_stars desc, levels_completed desc)
|
||||
//!
|
||||
//! 静态前端 + SPA fallback 由 cube-core::base 处理。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::{
|
||||
extract::State,
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json as JsonResp},
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
type Db = Arc<Mutex<Connection>>;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
cube_core::init_tracing();
|
||||
|
||||
let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "/data/app.db".into());
|
||||
let dist = std::env::var("SIMPLEASM_DIST_DIR").unwrap_or_else(|_| "/dist".into());
|
||||
|
||||
let conn = Connection::open(&db_path).expect("open sqlite");
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
CREATE TABLE IF NOT EXISTS players (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS progress (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
player_id INTEGER NOT NULL,
|
||||
level_id INTEGER NOT NULL,
|
||||
stars INTEGER NOT NULL DEFAULT 0,
|
||||
code TEXT,
|
||||
completed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (player_id) REFERENCES players(id),
|
||||
UNIQUE (player_id, level_id)
|
||||
);",
|
||||
)
|
||||
.expect("init schema");
|
||||
tracing::info!(%db_path, "sqlite ready");
|
||||
|
||||
let db: Db = Arc::new(Mutex::new(conn));
|
||||
|
||||
let api = Router::new()
|
||||
.route("/health", get(|| async { "ok" }))
|
||||
.route("/players", post(create_or_get_player))
|
||||
.route("/progress", post(save_progress))
|
||||
.route("/leaderboard", get(leaderboard))
|
||||
.with_state(db);
|
||||
|
||||
let app = cube_core::base(dist).nest("/api", api);
|
||||
cube_core::serve(app, 8080).await
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PlayerCreate {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ProgressSave {
|
||||
player_id: i64,
|
||||
level_id: i64,
|
||||
stars: i64,
|
||||
#[serde(default)]
|
||||
code: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ProgressItem {
|
||||
stars: i64,
|
||||
code: String,
|
||||
completed: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct LeaderboardRow {
|
||||
name: String,
|
||||
total_stars: i64,
|
||||
levels_completed: i64,
|
||||
}
|
||||
|
||||
/// `POST /api/players` — 按 name upsert。返回 `{id, name, progress: {level_id: {...}}}`。
|
||||
async fn create_or_get_player(
|
||||
State(db): State<Db>,
|
||||
Json(data): Json<PlayerCreate>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let name = data.name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(AppError::bad_request("Name cannot be empty"));
|
||||
}
|
||||
|
||||
let conn = db.lock().unwrap();
|
||||
let player_id: i64 = match conn
|
||||
.query_row(
|
||||
"SELECT id FROM players WHERE name = ?1",
|
||||
params![name],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?
|
||||
{
|
||||
Some(id) => id,
|
||||
None => {
|
||||
conn.execute("INSERT INTO players (name) VALUES (?1)", params![name])?;
|
||||
conn.last_insert_rowid()
|
||||
}
|
||||
};
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT level_id, stars, code FROM progress WHERE player_id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![player_id])?;
|
||||
let mut progress: HashMap<String, ProgressItem> = HashMap::new();
|
||||
while let Some(r) = rows.next()? {
|
||||
let level_id: i64 = r.get(0)?;
|
||||
let stars: i64 = r.get(1)?;
|
||||
let code: String = r.get::<_, Option<String>>(2)?.unwrap_or_default();
|
||||
progress.insert(
|
||||
level_id.to_string(),
|
||||
ProgressItem { stars, code, completed: true },
|
||||
);
|
||||
}
|
||||
|
||||
Ok(JsonResp(json!({
|
||||
"id": player_id,
|
||||
"name": name,
|
||||
"progress": progress,
|
||||
})))
|
||||
}
|
||||
|
||||
/// `POST /api/progress` — 单关 upsert。stars 取 max(old, new),code 永远覆盖。
|
||||
async fn save_progress(
|
||||
State(db): State<Db>,
|
||||
Json(data): Json<ProgressSave>,
|
||||
) -> Result<impl IntoResponse, AppError> {
|
||||
let conn = db.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO progress (player_id, level_id, stars, code)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT (player_id, level_id)
|
||||
DO UPDATE SET stars = MAX(stars, excluded.stars),
|
||||
code = excluded.code,
|
||||
completed_at = CURRENT_TIMESTAMP",
|
||||
params![data.player_id, data.level_id, data.stars, data.code],
|
||||
)?;
|
||||
Ok(JsonResp(json!({ "success": true })))
|
||||
}
|
||||
|
||||
/// `GET /api/leaderboard` — top 50 by (total_stars desc, levels_completed desc)。
|
||||
async fn leaderboard(State(db): State<Db>) -> Result<impl IntoResponse, AppError> {
|
||||
let conn = db.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.name, COALESCE(SUM(pr.stars), 0) AS total_stars,
|
||||
COUNT(pr.id) AS levels_completed
|
||||
FROM players p
|
||||
LEFT JOIN progress pr ON p.id = pr.player_id
|
||||
GROUP BY p.id
|
||||
ORDER BY total_stars DESC, levels_completed DESC
|
||||
LIMIT 50",
|
||||
)?;
|
||||
let rows = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(LeaderboardRow {
|
||||
name: r.get(0)?,
|
||||
total_stars: r.get(1)?,
|
||||
levels_completed: r.get(2)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(JsonResp(json!(rows)))
|
||||
}
|
||||
|
||||
enum AppError {
|
||||
BadRequest(String),
|
||||
Db(rusqlite::Error),
|
||||
}
|
||||
|
||||
impl AppError {
|
||||
fn bad_request(msg: impl Into<String>) -> Self {
|
||||
Self::BadRequest(msg.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for AppError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
Self::Db(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for AppError {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg).into_response(),
|
||||
Self::Db(e) => {
|
||||
tracing::error!(error = %e, "sqlite error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "db error").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user