Initial commit: Simple ASM - ARM assembly learning game
10-level progressive game teaching ARM assembly basics: registers, arithmetic, bitwise ops, memory, branching, loops. Vue 3 + FastAPI + SQLite with K8s deployment.
This commit is contained in:
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
*.db-wal
|
||||||
|
.env
|
||||||
|
.vite/
|
||||||
|
.DS_Store
|
||||||
|
/tmp/
|
||||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:20-slim AS frontend-build
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY frontend/ .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
COPY backend/ ./backend/
|
||||||
|
COPY --from=frontend-build /app/frontend/dist ./frontend
|
||||||
|
ENV DB_PATH=/data/app.db
|
||||||
|
ENV FRONTEND_DIR=/app/frontend
|
||||||
|
EXPOSE 8000
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
27
Makefile
Normal file
27
Makefile
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
APP_NAME = simpleasm
|
||||||
|
REGISTRY = registry.oci.euphon.net
|
||||||
|
IMAGE = $(REGISTRY)/$(APP_NAME):latest
|
||||||
|
|
||||||
|
.PHONY: dev dev-backend dev-frontend build deploy
|
||||||
|
|
||||||
|
dev-backend:
|
||||||
|
uvicorn backend.main:app --reload --port 8000
|
||||||
|
|
||||||
|
dev-frontend:
|
||||||
|
cd frontend && npm run dev
|
||||||
|
|
||||||
|
sync:
|
||||||
|
rsync -az --exclude node_modules --exclude .git --exclude __pycache__ --exclude '*.db' . oci:/tmp/$(APP_NAME)-build/
|
||||||
|
|
||||||
|
build: sync
|
||||||
|
ssh oci "cd /tmp/$(APP_NAME)-build && docker build -t $(IMAGE) ."
|
||||||
|
|
||||||
|
deploy: build
|
||||||
|
ssh oci "docker push $(IMAGE)"
|
||||||
|
ssh oci "sudo k3s kubectl apply -f /tmp/$(APP_NAME)-build/deploy/namespace.yaml"
|
||||||
|
@ssh oci "sudo k3s kubectl get secret regcred -n $(APP_NAME) 2>/dev/null" || \
|
||||||
|
ssh oci "sudo k3s kubectl get secret regcred -n guitar -o yaml | sed 's/namespace: guitar/namespace: $(APP_NAME)/;/resourceVersion/d;/uid/d;/creationTimestamp/d' | sudo k3s kubectl apply -f -"
|
||||||
|
ssh oci "sudo k3s kubectl apply -f /tmp/$(APP_NAME)-build/deploy/"
|
||||||
|
ssh oci "sudo k3s kubectl rollout restart deployment/$(APP_NAME) -n $(APP_NAME)"
|
||||||
|
ssh oci "sudo k3s kubectl rollout status deployment/$(APP_NAME) -n $(APP_NAME) --timeout=120s"
|
||||||
|
@echo "Deployed to https://asm.oci.euphon.net"
|
||||||
34
backend/database.py
Normal file
34
backend/database.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import aiosqlite
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "app.db")
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
db = await aiosqlite.connect(DB_PATH)
|
||||||
|
db.row_factory = aiosqlite.Row
|
||||||
|
await db.execute("PRAGMA journal_mode=WAL")
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
async def init_db():
|
||||||
|
db = await get_db()
|
||||||
|
await db.executescript("""
|
||||||
|
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)
|
||||||
|
);
|
||||||
|
""")
|
||||||
|
await db.commit()
|
||||||
|
await db.close()
|
||||||
119
backend/main.py
Normal file
119
backend/main.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import os
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from .database import get_db, init_db
|
||||||
|
|
||||||
|
FRONTEND_DIR = os.environ.get("FRONTEND_DIR", "../frontend/dist")
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
await init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
|
class PlayerCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressSave(BaseModel):
|
||||||
|
player_id: int
|
||||||
|
level_id: int
|
||||||
|
stars: int
|
||||||
|
code: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/health")
|
||||||
|
async def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/players")
|
||||||
|
async def create_or_get_player(data: PlayerCreate):
|
||||||
|
name = data.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "名字不能为空")
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
row = await db.execute_fetchall(
|
||||||
|
"SELECT id, name FROM players WHERE name = ?", (name,)
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
player_id = row[0][0]
|
||||||
|
else:
|
||||||
|
cursor = await db.execute(
|
||||||
|
"INSERT INTO players (name) VALUES (?)", (name,)
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
player_id = cursor.lastrowid
|
||||||
|
|
||||||
|
progress = await db.execute_fetchall(
|
||||||
|
"SELECT level_id, stars, code FROM progress WHERE player_id = ?",
|
||||||
|
(player_id,),
|
||||||
|
)
|
||||||
|
progress_dict = {
|
||||||
|
r[0]: {"stars": r[1], "code": r[2], "completed": True}
|
||||||
|
for r in progress
|
||||||
|
}
|
||||||
|
return {"id": player_id, "name": name, "progress": progress_dict}
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/progress")
|
||||||
|
async def save_progress(data: ProgressSave):
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
await db.execute(
|
||||||
|
"""INSERT INTO progress (player_id, level_id, stars, code)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT (player_id, level_id)
|
||||||
|
DO UPDATE SET stars = MAX(stars, excluded.stars),
|
||||||
|
code = excluded.code,
|
||||||
|
completed_at = CURRENT_TIMESTAMP""",
|
||||||
|
(data.player_id, data.level_id, data.stars, data.code),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
return {"success": True}
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/leaderboard")
|
||||||
|
async def leaderboard():
|
||||||
|
db = await get_db()
|
||||||
|
try:
|
||||||
|
rows = await db.execute_fetchall("""
|
||||||
|
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
|
||||||
|
""")
|
||||||
|
return [
|
||||||
|
{"name": r[0], "total_stars": r[1], "levels_completed": r[2]}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
await db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# Serve frontend static files
|
||||||
|
if os.path.isdir(FRONTEND_DIR):
|
||||||
|
@app.get("/{full_path:path}")
|
||||||
|
async def serve_spa(full_path: str):
|
||||||
|
file_path = os.path.join(FRONTEND_DIR, full_path)
|
||||||
|
if full_path and os.path.isfile(file_path):
|
||||||
|
return FileResponse(file_path)
|
||||||
|
index = os.path.join(FRONTEND_DIR, "index.html")
|
||||||
|
return FileResponse(index)
|
||||||
42
deploy/deployment.yaml
Normal file
42
deploy/deployment.yaml
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: simpleasm
|
||||||
|
namespace: simpleasm
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: simpleasm
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: simpleasm
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: simpleasm
|
||||||
|
image: registry.oci.euphon.net/simpleasm:latest
|
||||||
|
imagePullPolicy: Always
|
||||||
|
ports:
|
||||||
|
- containerPort: 8000
|
||||||
|
env:
|
||||||
|
- name: DB_PATH
|
||||||
|
value: /data/app.db
|
||||||
|
- name: FRONTEND_DIR
|
||||||
|
value: /app/frontend
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 64Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
volumeMounts:
|
||||||
|
- name: data
|
||||||
|
mountPath: /data
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
|
volumes:
|
||||||
|
- name: data
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: simpleasm-data
|
||||||
23
deploy/ingress.yaml
Normal file
23
deploy/ingress.yaml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: simpleasm
|
||||||
|
namespace: simpleasm
|
||||||
|
annotations:
|
||||||
|
traefik.ingress.kubernetes.io/router.tls.certresolver: le
|
||||||
|
spec:
|
||||||
|
ingressClassName: traefik
|
||||||
|
rules:
|
||||||
|
- host: asm.oci.euphon.net
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: simpleasm
|
||||||
|
port:
|
||||||
|
number: 80
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- asm.oci.euphon.net
|
||||||
4
deploy/namespace.yaml
Normal file
4
deploy/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: simpleasm
|
||||||
11
deploy/pvc.yaml
Normal file
11
deploy/pvc.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: simpleasm-data
|
||||||
|
namespace: simpleasm
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 1Gi
|
||||||
11
deploy/service.yaml
Normal file
11
deploy/service.yaml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: simpleasm
|
||||||
|
namespace: simpleasm
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: simpleasm
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8000
|
||||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Simple ASM - 汇编探险家</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=Noto+Sans+SC:wght@400;500;700&display=swap" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1197
frontend/package-lock.json
generated
Normal file
1197
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "simpleasm",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"vite": "^5.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
frontend/src/App.vue
Normal file
57
frontend/src/App.vue
Normal file
@@ -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: 'Noto Sans SC', 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>
|
||||||
106
frontend/src/components/CodeEditor.vue
Normal file
106
frontend/src/components/CodeEditor.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<template>
|
||||||
|
<div class="code-editor" :class="{ running: readOnly }">
|
||||||
|
<div class="editor-header">
|
||||||
|
<span>代码</span>
|
||||||
|
<span class="line-count mono">{{ instrCount }} 条指令</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|DIV|MOD|AND|ORR|EOR|MVN|LSL|LSR|CMP|BEQ|BNE|BGT|BLT|BGE|BLE|LDR|STR|PUSH|POP|OUT|HLT|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>
|
||||||
106
frontend/src/components/LevelComplete.vue
Normal file
106
frontend/src/components/LevelComplete.vue
Normal file
@@ -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>恭喜通关!</h2>
|
||||||
|
<p class="lname">{{ level.icon }} {{ 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">代码行数</span><span class="sv">{{ instructionCount }}</span></div>
|
||||||
|
<div class="stat"><span class="sl">获得星星</span><span class="sv">{{ stars }} / 3</span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn-retry" @click="$emit('retry')">再试一次</button>
|
||||||
|
<button v-if="level.id < 10" class="btn-next" @click="$emit('next')">下一关 →</button>
|
||||||
|
<button v-else class="btn-next" @click="$emit('close')">返回关卡</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>
|
||||||
139
frontend/src/components/MachineState.vue
Normal file
139
frontend/src/components/MachineState.vue
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
<template>
|
||||||
|
<div class="machine-state">
|
||||||
|
<h3>⚙ 机器状态</h3>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h4>寄存器</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>标志位</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.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>内存</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>
|
||||||
72
frontend/src/components/OutputConsole.vue
Normal file
72
frontend/src/components/OutputConsole.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div class="output-console">
|
||||||
|
<div class="console-header">
|
||||||
|
<span>输出</span>
|
||||||
|
<button v-if="messages.length" @click="$emit('clear')" class="clear-btn">清除</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">等待运行...</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>
|
||||||
99
frontend/src/components/TutorialPanel.vue
Normal file
99
frontend/src/components/TutorialPanel.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<template>
|
||||||
|
<div class="tutorial-panel" :class="{ collapsed: !visible }">
|
||||||
|
<button class="toggle-btn" @click="$emit('toggle')" :title="visible ? '收起' : '展开教程'">
|
||||||
|
{{ visible ? '◀' : '▶' }}
|
||||||
|
</button>
|
||||||
|
<div v-show="visible" class="tutorial-content">
|
||||||
|
<div class="goal-box">
|
||||||
|
<h4>🎯 目标</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>
|
||||||
376
frontend/src/lib/levels.js
Normal file
376
frontend/src/lib/levels.js
Normal file
@@ -0,0 +1,376 @@
|
|||||||
|
export const levels = [
|
||||||
|
// ===== Level 1: 认识寄存器 =====
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
title: '认识寄存器',
|
||||||
|
subtitle: '小机器人的记忆槽',
|
||||||
|
icon: '🤖',
|
||||||
|
description: '学习 MOV 指令给寄存器赋值',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: '什么是寄存器?',
|
||||||
|
text: 'CPU 是计算机的大脑,而**寄存器**是它手边的小抽屉 —— 速度最快的存储空间!我们的机器有 8 个寄存器:**R0** 到 **R7**。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'MOV 指令',
|
||||||
|
text: '`MOV` 把一个数字放进寄存器。注意数字前面要加 **#** 号,表示"这是一个数值":',
|
||||||
|
code: 'MOV R0, #42 ; 把 42 放进 R0\nMOV R1, #100 ; 把 100 放进 R1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'HLT 指令',
|
||||||
|
text: '程序最后要写 `HLT`(halt = 停止),告诉机器"运行结束!"',
|
||||||
|
code: 'MOV R0, #42\nHLT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: '把数字 **42** 放进 **R0** 寄存器',
|
||||||
|
initialState: {},
|
||||||
|
testCases: [{ init: {}, expected: { registers: { R0: 42 } } }],
|
||||||
|
hints: [
|
||||||
|
'MOV 的格式:MOV 寄存器, #数字',
|
||||||
|
'试试:MOV R0, #???',
|
||||||
|
'答案:MOV R0, #42 然后 HLT',
|
||||||
|
],
|
||||||
|
starThresholds: [2, 3, 5],
|
||||||
|
starterCode: '; 把 42 放进 R0 寄存器\n; 提示:数字前面要加 # 号\n\n\nHLT',
|
||||||
|
showMemory: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Level 2: 数据搬运工 =====
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: '数据搬运工',
|
||||||
|
subtitle: '寄存器之间的复制',
|
||||||
|
icon: '📦',
|
||||||
|
description: '学习在寄存器之间复制数据',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: '寄存器间复制',
|
||||||
|
text: 'MOV 也能把一个寄存器的值**复制**到另一个(这时不需要 # 号):',
|
||||||
|
code: 'MOV R1, R0 ; 把 R0 的值复制到 R1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '复制,不是移动!',
|
||||||
|
text: '虽然叫 "MOV"(移动),但其实是**复制**。执行后 R0 的值不变,R1 变成和 R0 一样。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: 'R0 已经有值 **7**,把它复制到 **R1** 和 **R2**',
|
||||||
|
initialState: { registers: { R0: 7 } },
|
||||||
|
testCases: [{ init: {}, expected: { registers: { R0: 7, R1: 7, R2: 7 } } }],
|
||||||
|
hints: [
|
||||||
|
'MOV 寄存器, 寄存器 —— 把右边复制到左边',
|
||||||
|
'MOV R1, R0 可以把 R0 复制到 R1',
|
||||||
|
'答案:MOV R1, R0 / MOV R2, R0 / HLT',
|
||||||
|
],
|
||||||
|
starThresholds: [3, 4, 6],
|
||||||
|
starterCode: '; R0 = 7\n; 把 R0 复制到 R1 和 R2\n\n\nHLT',
|
||||||
|
showMemory: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Level 3: 加减法 =====
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
title: '加减法',
|
||||||
|
subtitle: '三操作数的威力',
|
||||||
|
icon: '➕',
|
||||||
|
description: '学习 ADD 和 SUB 指令',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: 'ADD —— 加法(三操作数)',
|
||||||
|
text: 'ARM 风格的加法很酷:**三个操作数**!第一个放结果,后两个是被运算的值:',
|
||||||
|
code: 'ADD R2, R0, R1 ; R2 = R0 + R1\nADD R0, R0, #10 ; R0 = R0 + 10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SUB —— 减法',
|
||||||
|
text: 'SUB 同理,也是三操作数:',
|
||||||
|
code: 'SUB R2, R0, R1 ; R2 = R0 - R1\nSUB R0, R0, #5 ; R0 = R0 - 5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '好处',
|
||||||
|
text: '三操作数的好处:可以直接把结果放到新的寄存器,**不用先复制**!',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: 'R0=**15**,R1=**27**,计算 R0+R1 存入 **R2**(R0和R1不变)',
|
||||||
|
initialState: { registers: { R0: 15, R1: 27 } },
|
||||||
|
testCases: [{ init: {}, expected: { registers: { R0: 15, R1: 27, R2: 42 } } }],
|
||||||
|
hints: [
|
||||||
|
'ADD 第一个参数放结果,后两个参数相加',
|
||||||
|
'ADD R2, R0, R1 —— 结果存入 R2',
|
||||||
|
'答案:ADD R2, R0, R1 / HLT',
|
||||||
|
],
|
||||||
|
starThresholds: [2, 3, 5],
|
||||||
|
starterCode: '; R0=15, R1=27\n; 计算 R0 + R1,结果存入 R2\n\n\nHLT',
|
||||||
|
showMemory: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Level 4: 乘法与除法 =====
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
title: '乘法与除法',
|
||||||
|
subtitle: '更强的算术能力',
|
||||||
|
icon: '✖️',
|
||||||
|
description: '学习 MUL 和 DIV 指令',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: 'MUL —— 乘法',
|
||||||
|
text: 'MUL 也是三操作数:',
|
||||||
|
code: 'MOV R0, #6\nMOV R1, #7\nMUL R2, R0, R1 ; R2 = 6 × 7 = 42',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'DIV —— 除法(取整)',
|
||||||
|
text: 'DIV 做整数除法(只留整数部分):',
|
||||||
|
code: 'MOV R0, #100\nMOV R1, #4\nDIV R2, R0, R1 ; R2 = 100 ÷ 4 = 25',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: '计算 **6 × 7** 存入 R0,**100 ÷ 4** 存入 R1',
|
||||||
|
initialState: {},
|
||||||
|
testCases: [{ init: {}, expected: { registers: { R0: 42, R1: 25 } } }],
|
||||||
|
hints: [
|
||||||
|
'先 MOV 数字到寄存器,再 MUL/DIV',
|
||||||
|
'MUL R0, R2, R3 可以把 R2×R3 的结果放到 R0',
|
||||||
|
'答案:MOV R2, #6 / MOV R3, #7 / MUL R0, R2, R3 / MOV R2, #100 / MOV R3, #4 / DIV R1, R2, R3 / HLT',
|
||||||
|
],
|
||||||
|
starThresholds: [7, 9, 12],
|
||||||
|
starterCode: '; 计算 6×7 存入 R0\n; 计算 100÷4 存入 R1\n\n\nHLT',
|
||||||
|
showMemory: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Level 5: 位运算魔法 =====
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
title: '位运算魔法',
|
||||||
|
subtitle: '0和1的秘密',
|
||||||
|
icon: '🔮',
|
||||||
|
description: '学习 AND、ORR、EOR、MVN 指令',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: '二进制世界',
|
||||||
|
text: '计算机内部用 **0** 和 **1** 存储一切。42 的二进制是 `00101010`。右边面板会显示每个寄存器的二进制值!',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AND —— 都是1才是1',
|
||||||
|
text: 'AND 逐位比较,两个都是 1 结果才是 1。可以用来"提取"某些位:',
|
||||||
|
code: '; 11111111 (255)\n; AND 00001111 (15)\n; = 00001111 (15)\nAND R0, R0, #15',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '其他位运算',
|
||||||
|
text: '**ORR** = 有一个1就是1 (OR)\n**EOR** = 不同才是1 (XOR)\n**MVN** = 全部翻转 (NOT)',
|
||||||
|
code: 'ORR R0, R0, #240 ; 设置高4位\nEOR R0, R0, #255 ; 翻转低8位\nMVN R0, R0 ; 翻转所有位',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: 'R0 = **255** (二进制 11111111),用 AND 提取**低4位**,使 R0 变成 **15**',
|
||||||
|
initialState: { registers: { R0: 255 } },
|
||||||
|
testCases: [{ init: {}, expected: { registers: { R0: 15 } } }],
|
||||||
|
hints: [
|
||||||
|
'AND 用来保留某些位,把其他位清零',
|
||||||
|
'低4位的掩码是 15(二进制 00001111)',
|
||||||
|
'答案:AND R0, R0, #15 / HLT',
|
||||||
|
],
|
||||||
|
starThresholds: [2, 3, 5],
|
||||||
|
starterCode: '; R0 = 255 (二进制 11111111)\n; 用 AND 提取低4位\n\n\nHLT',
|
||||||
|
showMemory: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Level 6: 移位操作 =====
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
title: '移位操作',
|
||||||
|
subtitle: '位的舞蹈',
|
||||||
|
icon: '↔️',
|
||||||
|
description: '学习 LSL 和 LSR 指令',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: 'LSL —— 逻辑左移',
|
||||||
|
text: '所有位向左移,右边补0。**左移1位 = 乘以2**,左移3位 = 乘以8:',
|
||||||
|
code: '; 5 = 00000101\nLSL R0, R0, #1 ; 00001010 = 10 (×2)\nLSL R0, R0, #1 ; 00010100 = 20 (×2)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'LSR —— 逻辑右移',
|
||||||
|
text: '所有位向右移,左边补0。**右移1位 = 除以2**:',
|
||||||
|
code: 'MOV R0, #40\nLSR R0, R0, #1 ; 20 (÷2)\nLSR R0, R0, #2 ; 5 (÷4)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '程序员的技巧',
|
||||||
|
text: '在真实的 ARM 处理器中,移位比乘除快得多!`LSL R0, R0, #3` 比 `MUL R0, R0, #8` 高效。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: 'R0 = **5**,只用**移位操作**把它变成 **40**(40 = 5 × 8 = 5 × 2³)',
|
||||||
|
initialState: { registers: { R0: 5 } },
|
||||||
|
testCases: [{ init: {}, expected: { registers: { R0: 40 } } }],
|
||||||
|
hints: [
|
||||||
|
'8 = 2³,乘以8就是左移3位',
|
||||||
|
'LSL R0, R0, #3',
|
||||||
|
'就这一条指令!',
|
||||||
|
],
|
||||||
|
starThresholds: [2, 3, 5],
|
||||||
|
blockedOps: ['MUL', 'DIV'],
|
||||||
|
starterCode: '; R0 = 5\n; 用 LSL 让 R0 变成 40(不能用 MUL)\n\n\nHLT',
|
||||||
|
showMemory: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Level 7: 内存读写 =====
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
title: '内存读写',
|
||||||
|
subtitle: '打开更大的空间',
|
||||||
|
icon: '💾',
|
||||||
|
description: '学习 LDR 和 STR 指令',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: '什么是内存?',
|
||||||
|
text: '寄存器只有8个,太少了!**内存**像一排256格的柜子,每格有编号(0-255)。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'LDR —— 从内存读取',
|
||||||
|
text: '先把地址放进寄存器,再用 `LDR` 从那个地址读数据:',
|
||||||
|
code: 'MOV R1, #0 ; 地址 = 0\nLDR R0, [R1] ; R0 = 内存[0]',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'STR —— 写入内存',
|
||||||
|
text: '`STR` 把寄存器的值写到内存:',
|
||||||
|
code: 'MOV R1, #5 ; 地址 = 5\nSTR R0, [R1] ; 内存[5] = R0',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '偏移寻址',
|
||||||
|
text: '还可以加偏移量:`[R1, #4]` 表示地址 R1+4:',
|
||||||
|
code: 'MOV R1, #0\nLDR R0, [R1, #0] ; 内存[0]\nLDR R2, [R1, #1] ; 内存[1]',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: '内存[0]=**10**,内存[1]=**20**,计算它们的和存入 **内存[2]**',
|
||||||
|
initialState: { memory: { 0: 10, 1: 20 } },
|
||||||
|
testCases: [{ init: {}, expected: { memory: { 2: 30 } } }],
|
||||||
|
hints: [
|
||||||
|
'先用 LDR 把内存值读到寄存器,算完用 STR 写回',
|
||||||
|
'MOV R3, #0 设基地址,LDR R0, [R3, #0] 读第一个值',
|
||||||
|
'答案:MOV R3, #0 / LDR R0, [R3, #0] / LDR R1, [R3, #1] / ADD R2, R0, R1 / STR R2, [R3, #2] / HLT',
|
||||||
|
],
|
||||||
|
starThresholds: [6, 8, 10],
|
||||||
|
starterCode: '; 内存[0]=10, 内存[1]=20\n; 计算它们的和,存入内存[2]\n;\n; 提示:先 MOV 一个地址到寄存器\n; 然后用 LDR/STR 读写内存\n\n\nHLT',
|
||||||
|
showMemory: true,
|
||||||
|
memoryRange: [0, 15],
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Level 8: 比较与跳转 =====
|
||||||
|
{
|
||||||
|
id: 8,
|
||||||
|
title: '比较与跳转',
|
||||||
|
subtitle: '让程序会做决定',
|
||||||
|
icon: '🔀',
|
||||||
|
description: '学习 CMP 和条件分支指令',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: '到目前为止...',
|
||||||
|
text: '程序都是从头到尾顺序执行。但有了**分支**,程序就能做决定了!',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'CMP —— 比较',
|
||||||
|
text: '`CMP` 比较两个值,记住比较结果(不会改变它们的值):',
|
||||||
|
code: 'CMP R0, #10 ; 比较 R0 和 10',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '条件分支',
|
||||||
|
text: '比较后用 **B** (Branch=分支) 跳转:',
|
||||||
|
code: 'BEQ label ; 等于则跳(Equal)\nBNE label ; 不等则跳(Not Equal)\nBGT label ; 大于则跳(Greater Than)\nBLT label ; 小于则跳(Less Than)\nB label ; 无条件跳',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '标签',
|
||||||
|
text: '**标签**是代码里的记号,分支指令跳到标签位置。标签后面加冒号:',
|
||||||
|
code: 'CMP R0, #10\nBGT big\nMOV R1, #0 ; R0 <= 10\nB done ; 跳过下面\nbig:\nMOV R1, #1 ; R0 > 10\ndone:\nHLT',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: 'R0=**15**。如果 R0 > 10 则 R1 = **1**;否则 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: [
|
||||||
|
'先设 R1=#0(默认),再比较 R0 和 10',
|
||||||
|
'如果 R0 > 10,跳到标签把 R1 改成 1',
|
||||||
|
'答案:MOV R1, #0 / CMP R0, #10 / BLE done / MOV R1, #1 / done: HLT',
|
||||||
|
],
|
||||||
|
starThresholds: [5, 7, 9],
|
||||||
|
starterCode: '; 如果 R0 > 10,则 R1 = 1\n; 否则 R1 = 0\n\n\nHLT',
|
||||||
|
showMemory: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Level 9: 循环 =====
|
||||||
|
{
|
||||||
|
id: 9,
|
||||||
|
title: '循环',
|
||||||
|
subtitle: '重复的力量',
|
||||||
|
icon: '🔄',
|
||||||
|
description: '用分支指令创建循环',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: '什么是循环?',
|
||||||
|
text: '循环让一段代码**反复执行**。在汇编中,循环就是**跳回前面的标签**!',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '循环结构',
|
||||||
|
text: '①初始化 ②做事 ③更新计数器 ④判断+跳回:',
|
||||||
|
code: 'MOV R4, #0 ; ① 初始化\nloop: ; 循环开始\n ADD R4, R4, #1 ; ②③ 计数+1\n CMP R4, #5 ; ④ 到5了吗?\n BLE loop ; 没到就跳回\nHLT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '注意!',
|
||||||
|
text: '忘了更新计数器 = **死循环**(别担心,超过10000步会自动停止)。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: '计算 **1+2+3+...+10** 的和存入 **R0**(答案是55)',
|
||||||
|
initialState: {},
|
||||||
|
testCases: [{ init: {}, expected: { registers: { R0: 55 } } }],
|
||||||
|
hints: [
|
||||||
|
'R0 累加结果,R4 做计数器(1到10)',
|
||||||
|
'循环体:ADD R0, R0, R4 / ADD R4, R4, #1 / CMP R4, #10 / BLE loop',
|
||||||
|
'完整:MOV R0, #0 / MOV R4, #1 / loop: ADD R0, R0, R4 / ADD R4, R4, #1 / CMP R4, #10 / BLE loop / HLT',
|
||||||
|
],
|
||||||
|
starThresholds: [7, 9, 12],
|
||||||
|
starterCode: '; 计算 1+2+3+...+10\n; 结果存入 R0\n;\n; 提示:用一个寄存器做计数器\n\n\nHLT',
|
||||||
|
showMemory: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== Level 10: 终极挑战 =====
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
title: '终极挑战',
|
||||||
|
subtitle: '寻找最大值',
|
||||||
|
icon: '🏆',
|
||||||
|
description: '综合运用所有技能!',
|
||||||
|
tutorial: [
|
||||||
|
{
|
||||||
|
title: '最后一关!',
|
||||||
|
text: '你已经学会了寄存器、算术、位运算、内存、分支和循环。现在把**所有技能**结合起来!',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '挑战说明',
|
||||||
|
text: '内存地址 0-4 存了5个数字。你要找到**最大值**和它的**位置**。需要:循环 + 内存读取 + 比较分支。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '解题思路',
|
||||||
|
text: '1. 假设第一个数最大(R0=内存[0],R1=位置0)\n2. 循环检查剩余的数\n3. 如果发现更大的,更新最大值和位置\n4. 直到检查完全部5个数',
|
||||||
|
code: '; 伪代码:\n; R0 = max = mem[0]\n; R1 = maxIdx = 0\n; for R4 = 1 to 4:\n; R5 = mem[R4]\n; if R5 > R0: R0=R5, R1=R4',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
goal: '内存[0..4] 有5个数,找出**最大值**存入 **R0**,其**位置**存入 **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=最大值, R1=位置, R4=循环计数器, R5=当前值, R3=基地址',
|
||||||
|
'用 LDR R5, [R3, R4] 不行的话,可以用 R3 当地址:MOV R3, R4 / LDR R5, [R3]',
|
||||||
|
'循环体:把 R4 当地址读内存 → CMP R5, R0 → BLE skip → 更新 R0,R1 → skip: ADD R4, R4, #1 → CMP R4, #5 → BLT loop',
|
||||||
|
],
|
||||||
|
starThresholds: [12, 15, 20],
|
||||||
|
starterCode: '; 内存[0..4] = [5, 3, 8, 1, 7]\n; 找最大值存入 R0,位置存入 R1\n;\n; 提示:用 R4 做循环变量\n; 用 MOV + LDR 读取内存\n\n\nHLT',
|
||||||
|
showMemory: true,
|
||||||
|
memoryRange: [0, 15],
|
||||||
|
},
|
||||||
|
]
|
||||||
375
frontend/src/lib/vm.js
Normal file
375
frontend/src/lib/vm.js
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
const REGISTERS = ['R0','R1','R2','R3','R4','R5','R6','R7']
|
||||||
|
const OPCODES = [
|
||||||
|
'MOV','ADD','SUB','MUL','DIV','MOD',
|
||||||
|
'AND','ORR','EOR','MVN','LSL','LSR',
|
||||||
|
'CMP','B','BEQ','BNE','BGT','BLT','BGE','BLE',
|
||||||
|
'LDR','STR','PUSH','POP','OUT','HLT','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: `语法错误: ${lines[i].trim()}` }); continue }
|
||||||
|
|
||||||
|
const opcode = parts[1].toUpperCase()
|
||||||
|
if (!OPCODES.includes(opcode)) { errors.push({ line: i, msg: `未知指令: ${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: `无法识别的操作数: ${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(`不能读取: ${JSON.stringify(op)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setReg(op, val, changes) {
|
||||||
|
val = ((val % 65536) + 65536) % 65536
|
||||||
|
if (op.type !== 'reg') throw new Error('目标必须是寄存器')
|
||||||
|
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 = '超过最大执行步数 (10000),可能是死循环哦!'; 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 'HLT': 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 'DIV': {
|
||||||
|
const r = arith3(ops, (a,b) => { if(b===0) throw new Error('除以零!'); return Math.floor(a/b) })
|
||||||
|
setReg(ops[0], r, changes); updateFlags(r); break
|
||||||
|
}
|
||||||
|
case 'MOD': {
|
||||||
|
const r = arith3(ops, (a,b) => { if(b===0) throw new Error('除以零!'); 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(`未知标签: ${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 需要内存地址,如 [R0] 或 [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 需要内存地址,如 [R0] 或 [R0, #4]')
|
||||||
|
setMem(addr, getVal(ops[0]), changes)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'PUSH': {
|
||||||
|
if (state.stack.length >= 64) throw new Error('栈溢出!')
|
||||||
|
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('栈为空!')
|
||||||
|
setReg(ops[0], state.stack.pop(), changes)
|
||||||
|
changes.push({ type: 'stack_pop' })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'OUT': {
|
||||||
|
const v = getVal(ops[0])
|
||||||
|
state.output.push(v)
|
||||||
|
changes.push({ type: 'output', val: v })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
state.error = `第 ${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 = '超过最大执行步数 (10000),可能是死循环哦!'
|
||||||
|
}
|
||||||
|
|
||||||
|
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.instructions = []; state.labels = {}; 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} 应该是 ${v},但实际是 ${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: `内存[${a}] 应该是 ${v},但实际是 ${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: `输出第${i+1}个值应该是 ${exp.output[i]},但实际是 ${vm.state.output[i] ?? '无'}` }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { passed: true, msg: '通过!' }
|
||||||
|
}
|
||||||
9
frontend/src/main.js
Normal file
9
frontend/src/main.js
Normal file
@@ -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')
|
||||||
12
frontend/src/router/index.js
Normal file
12
frontend/src/router/index.js
Normal file
@@ -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,
|
||||||
|
})
|
||||||
84
frontend/src/stores/game.js
Normal file
84
frontend/src/stores/game.js
Normal file
@@ -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))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
111
frontend/src/views/LevelSelectView.vue
Normal file
111
frontend/src/views/LevelSelectView.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<template>
|
||||||
|
<div class="level-select">
|
||||||
|
<header>
|
||||||
|
<router-link to="/" class="back">← 首页</router-link>
|
||||||
|
<div class="player-info">
|
||||||
|
<span class="player-name">{{ store.playerName }}</span>
|
||||||
|
<span class="total-stars">{{ store.totalStars }} ⭐</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<h1>选择关卡</h1>
|
||||||
|
<p class="desc">完成当前关卡解锁下一关!每关最多3颗星。</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>
|
||||||
|
<div class="level-icon">{{ level.icon }}</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>
|
||||||
266
frontend/src/views/LevelView.vue
Normal file
266
frontend/src/views/LevelView.vue
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
<template>
|
||||||
|
<div class="level-page">
|
||||||
|
<header class="level-header">
|
||||||
|
<router-link to="/levels" class="back-link">← 返回</router-link>
|
||||||
|
<div class="hdr-center">
|
||||||
|
<span class="level-badge">{{ level.icon }} Level {{ level.id }}</span>
|
||||||
|
<h1>{{ level.title }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="hdr-stars">
|
||||||
|
<span v-for="s in 3" :key="s" :class="{ earned: s <= bestStars }">★</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="level-layout">
|
||||||
|
<TutorialPanel :level="level" :visible="showTut" @toggle="showTut = !showTut" class="panel-tut" />
|
||||||
|
|
||||||
|
<div class="panel-center">
|
||||||
|
<CodeEditor v-model="code" :currentLine="curLine" :errorLine="errLine" :readOnly="mode !== 'edit'" />
|
||||||
|
<div class="controls">
|
||||||
|
<div class="ctrls-left">
|
||||||
|
<button class="cb run" @click="runCode" :disabled="mode === 'auto'">▶ 运行</button>
|
||||||
|
<button class="cb step" @click="stepCode" :disabled="mode === 'auto'">
|
||||||
|
→{{ mode === 'step' ? ' 下一步' : ' 单步' }}
|
||||||
|
</button>
|
||||||
|
<button v-if="mode === 'edit'" class="cb auto" @click="autoStep">⏩ 动画</button>
|
||||||
|
<button v-if="mode !== 'edit'" class="cb reset" @click="resetVM">↺ 重置</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="mode === 'auto'" class="speed">
|
||||||
|
<label>速度</label>
|
||||||
|
<input type="range" min="50" max="800" :value="800-delay" @input="delay=800-+$event.target.value">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<OutputConsole :output="out" :error="vmErr" :status="statusMsg" @clear="clearOut" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MachineState :registers="regs" :flags="fl" :memory="mem" :stack="stk"
|
||||||
|
:showMemory="level.showMemory" :memoryRange="level.memoryRange"
|
||||||
|
:changes="changes" class="panel-state" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hints-bar">
|
||||||
|
<button class="hint-btn" @click="nextHint" :disabled="hintIdx >= level.hints.length - 1">
|
||||||
|
💡 提示 ({{ Math.min(hintIdx+2, level.hints.length) }}/{{ level.hints.length }})
|
||||||
|
</button>
|
||||||
|
<div v-if="hintIdx >= 0" class="hint-text">{{ level.hints[hintIdx] }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LevelComplete v-if="showComp" :level="level" :stars="earnedStars" :instructionCount="iCount"
|
||||||
|
@next="goNext" @retry="doRetry" @close="showComp = false" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, onUnmounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useGameStore } from '../stores/game.js'
|
||||||
|
import { levels } from '../lib/levels.js'
|
||||||
|
import { createVM, countInstructions, validateLevel, parse } from '../lib/vm.js'
|
||||||
|
import TutorialPanel from '../components/TutorialPanel.vue'
|
||||||
|
import CodeEditor from '../components/CodeEditor.vue'
|
||||||
|
import OutputConsole from '../components/OutputConsole.vue'
|
||||||
|
import MachineState from '../components/MachineState.vue'
|
||||||
|
import LevelComplete from '../components/LevelComplete.vue'
|
||||||
|
|
||||||
|
const props = defineProps({ id: [String, Number] })
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useGameStore()
|
||||||
|
if (!store.isLoggedIn) router.replace('/')
|
||||||
|
|
||||||
|
const level = computed(() => levels.find(l => l.id === +props.id) || levels[0])
|
||||||
|
const bestStars = computed(() => store.progress[level.value.id]?.stars || 0)
|
||||||
|
|
||||||
|
const code = ref(store.progress[level.value.id]?.code || level.value.starterCode)
|
||||||
|
const mode = ref('edit')
|
||||||
|
const showTut = ref(true)
|
||||||
|
const showComp = ref(false)
|
||||||
|
const earnedStars = ref(0)
|
||||||
|
const hintIdx = ref(-1)
|
||||||
|
const delay = ref(200)
|
||||||
|
|
||||||
|
const EMPTY_REGS = { R0:0,R1:0,R2:0,R3:0,R4:0,R5:0,R6:0,R7:0 }
|
||||||
|
const regs = reactive({ ...EMPTY_REGS })
|
||||||
|
const fl = reactive({ zero:false, carry:false, negative:false, overflow:false })
|
||||||
|
const mem = ref(new Array(256).fill(0))
|
||||||
|
const stk = ref([])
|
||||||
|
const out = ref([])
|
||||||
|
const vmErr = ref('')
|
||||||
|
const statusMsg = ref('')
|
||||||
|
const curLine = ref(-1)
|
||||||
|
const errLine = ref(-1)
|
||||||
|
const changes = ref([])
|
||||||
|
const iCount = ref(0)
|
||||||
|
|
||||||
|
let vm = null
|
||||||
|
let autoTimer = null
|
||||||
|
|
||||||
|
function applyInitState() {
|
||||||
|
Object.assign(regs, EMPTY_REGS)
|
||||||
|
const m = new Array(256).fill(0)
|
||||||
|
const is = level.value.initialState
|
||||||
|
if (is) {
|
||||||
|
if (is.registers) Object.assign(regs, is.registers)
|
||||||
|
if (is.memory) for (const [a,v] of Object.entries(is.memory)) m[+a] = v
|
||||||
|
}
|
||||||
|
mem.value = m
|
||||||
|
fl.zero = false; fl.carry = false; fl.negative = false; fl.overflow = false
|
||||||
|
stk.value = []; out.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function initVM() {
|
||||||
|
vm = createVM()
|
||||||
|
if (level.value.blockedOps) {
|
||||||
|
const p = parse(code.value)
|
||||||
|
for (const i of p.instructions) {
|
||||||
|
if (level.value.blockedOps.includes(i.opcode)) {
|
||||||
|
vmErr.value = `本关不能使用 ${i.opcode} 指令哦!`; return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const r = vm.loadProgram(code.value)
|
||||||
|
if (vm.state.error) { vmErr.value = vm.state.error; if (r.errors?.length) errLine.value = r.errors[0].line; return false }
|
||||||
|
const is = level.value.initialState
|
||||||
|
if (is) {
|
||||||
|
if (is.registers) Object.assign(vm.state.registers, is.registers)
|
||||||
|
if (is.memory) for (const [a,v] of Object.entries(is.memory)) vm.state.memory[+a] = v
|
||||||
|
if (is.input) vm.state.input = [...is.input]
|
||||||
|
}
|
||||||
|
syncState(); return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncState() {
|
||||||
|
if (!vm) return
|
||||||
|
Object.assign(regs, vm.state.registers)
|
||||||
|
Object.assign(fl, vm.state.flags)
|
||||||
|
mem.value = [...vm.state.memory]
|
||||||
|
stk.value = [...vm.state.stack]
|
||||||
|
out.value = [...vm.state.output]
|
||||||
|
if (vm.state.error) vmErr.value = vm.state.error
|
||||||
|
}
|
||||||
|
|
||||||
|
function runCode() {
|
||||||
|
stopAuto(); vmErr.value = ''; errLine.value = -1; mode.value = 'run'
|
||||||
|
if (!initVM()) { mode.value = 'edit'; return }
|
||||||
|
vm.run(); syncState(); curLine.value = -1
|
||||||
|
iCount.value = countInstructions(code.value)
|
||||||
|
if (vm.state.error) {
|
||||||
|
statusMsg.value = '运行出错了'
|
||||||
|
if (vm.state.pc < vm.state.instructions.length) errLine.value = vm.state.instructions[vm.state.pc]?.srcLine ?? -1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepCode() {
|
||||||
|
if (mode.value === 'edit' || mode.value === 'run') {
|
||||||
|
vmErr.value = ''; errLine.value = -1; mode.value = 'step'
|
||||||
|
if (!initVM()) { mode.value = 'edit'; return }
|
||||||
|
statusMsg.value = '单步执行中...'
|
||||||
|
}
|
||||||
|
if (vm.state.halted || vm.state.error) { iCount.value = countInstructions(code.value); checkResult(); return }
|
||||||
|
const r = vm.step()
|
||||||
|
if (r) { changes.value = r.changes || []; curLine.value = r.srcLine ?? -1 }
|
||||||
|
syncState()
|
||||||
|
if (vm.state.halted || vm.state.error) { iCount.value = countInstructions(code.value); if (vm.state.halted) checkResult() }
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoStep() {
|
||||||
|
vmErr.value = ''; errLine.value = -1; mode.value = 'auto'
|
||||||
|
if (!initVM()) { mode.value = 'edit'; return }
|
||||||
|
statusMsg.value = '动画执行中...'; doAuto()
|
||||||
|
}
|
||||||
|
|
||||||
|
function doAuto() {
|
||||||
|
if (!vm || vm.state.halted || vm.state.error || mode.value !== 'auto') {
|
||||||
|
if (vm?.state.halted) { iCount.value = countInstructions(code.value); checkResult() }
|
||||||
|
syncState(); return
|
||||||
|
}
|
||||||
|
const r = vm.step()
|
||||||
|
if (r) { changes.value = r.changes || []; curLine.value = r.srcLine ?? -1 }
|
||||||
|
syncState()
|
||||||
|
autoTimer = setTimeout(doAuto, delay.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopAuto() { if (autoTimer) { clearTimeout(autoTimer); autoTimer = null } }
|
||||||
|
|
||||||
|
function resetVM() {
|
||||||
|
stopAuto(); vm = null; mode.value = 'edit'
|
||||||
|
curLine.value = -1; errLine.value = -1; vmErr.value = ''; statusMsg.value = ''
|
||||||
|
changes.value = []; applyInitState()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearOut() { out.value = []; vmErr.value = ''; statusMsg.value = '' }
|
||||||
|
|
||||||
|
function checkResult() {
|
||||||
|
const tv = createVM(); tv.loadProgram(code.value)
|
||||||
|
const r = validateLevel(level.value, tv)
|
||||||
|
if (r.passed) {
|
||||||
|
iCount.value = countInstructions(code.value)
|
||||||
|
const [s3, s2] = level.value.starThresholds
|
||||||
|
earnedStars.value = iCount.value <= s3 ? 3 : iCount.value <= s2 ? 2 : 1
|
||||||
|
store.saveProgress(level.value.id, earnedStars.value, code.value)
|
||||||
|
showComp.value = true; statusMsg.value = '通关!'
|
||||||
|
} else {
|
||||||
|
vmErr.value = r.msg; statusMsg.value = '还没通过,再试试!'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextHint() { if (hintIdx.value < level.value.hints.length - 1) hintIdx.value++ }
|
||||||
|
function goNext() { showComp.value = false; const n = level.value.id + 1; router.push(n <= 10 ? `/level/${n}` : '/levels') }
|
||||||
|
function doRetry() { showComp.value = false; resetVM() }
|
||||||
|
|
||||||
|
watch(() => props.id, () => {
|
||||||
|
stopAuto(); vm = null; mode.value = 'edit'
|
||||||
|
code.value = store.progress[level.value.id]?.code || level.value.starterCode
|
||||||
|
hintIdx.value = -1; showComp.value = false; curLine.value = -1; errLine.value = -1
|
||||||
|
vmErr.value = ''; statusMsg.value = ''; changes.value = []; applyInitState()
|
||||||
|
})
|
||||||
|
|
||||||
|
applyInitState()
|
||||||
|
onUnmounted(() => stopAuto())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.level-page { height: 100vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.level-header { display: flex; align-items: center; justify-content: space-between; padding: 8px 20px; background: var(--bg-card); border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||||
|
.back-link { color: var(--text-secondary); font-size: 14px; min-width: 60px; }
|
||||||
|
.hdr-center { text-align: center; }
|
||||||
|
.level-badge { font-size: 12px; color: var(--text-muted); }
|
||||||
|
.level-header h1 { font-size: 18px; font-weight: 600; }
|
||||||
|
.hdr-stars { display: flex; gap: 4px; font-size: 20px; min-width: 60px; justify-content: flex-end; }
|
||||||
|
.hdr-stars span { color: var(--border); }
|
||||||
|
.hdr-stars span.earned { color: var(--accent-yellow); }
|
||||||
|
|
||||||
|
.level-layout { flex: 1; display: grid; grid-template-columns: 260px 1fr 300px; gap: 10px; padding: 10px; overflow: hidden; min-height: 0; }
|
||||||
|
.panel-tut { overflow-y: auto; min-height: 0; }
|
||||||
|
.panel-center { display: flex; flex-direction: column; gap: 6px; min-height: 0; overflow: hidden; }
|
||||||
|
.panel-center .code-editor { flex: 1; min-height: 0; overflow: hidden; }
|
||||||
|
.panel-state { overflow-y: auto; min-height: 0; }
|
||||||
|
|
||||||
|
.controls { display: flex; align-items: center; justify-content: space-between; gap: 8px; flex-shrink: 0; }
|
||||||
|
.ctrls-left { display: flex; gap: 5px; }
|
||||||
|
.cb { padding: 6px 12px; font-size: 13px; font-weight: 500; }
|
||||||
|
.cb.run { background: var(--accent-green); color: #fff; }
|
||||||
|
.cb.run:hover { background: #0d9668; }
|
||||||
|
.cb.step { background: var(--accent-blue); color: #fff; }
|
||||||
|
.cb.step:hover { background: #2563eb; }
|
||||||
|
.cb.auto { background: var(--accent-purple); color: #fff; }
|
||||||
|
.cb.auto:hover { background: #7c3aed; }
|
||||||
|
.cb.reset { background: var(--bg-surface); color: var(--text-primary); border: 1px solid var(--border); }
|
||||||
|
.cb.reset:hover { background: var(--bg-hover); }
|
||||||
|
.cb:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.speed { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); }
|
||||||
|
.speed input[type="range"] { width: 100px; accent-color: var(--accent-purple); }
|
||||||
|
|
||||||
|
.hints-bar { display: flex; align-items: center; gap: 12px; padding: 7px 20px; background: var(--bg-card); border-top: 1px solid var(--border); flex-shrink: 0; }
|
||||||
|
.hint-btn { padding: 5px 14px; background: rgba(245,158,11,0.1); color: var(--accent-yellow); border: 1px solid rgba(245,158,11,0.3); font-size: 13px; white-space: nowrap; flex-shrink: 0; }
|
||||||
|
.hint-btn:hover:not(:disabled) { background: rgba(245,158,11,0.2); }
|
||||||
|
.hint-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.hint-text { font-size: 13px; color: var(--accent-yellow); font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.level-layout { grid-template-columns: 1fr; }
|
||||||
|
.panel-tut { max-height: 200px; }
|
||||||
|
.panel-state { max-height: 250px; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
158
frontend/src/views/WelcomeView.vue
Normal file
158
frontend/src/views/WelcomeView.vue
Normal file
@@ -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">汇编探险家</p>
|
||||||
|
</div>
|
||||||
|
<p class="intro">准备好探索计算机的内心世界了吗?<br>输入你的名字,开始冒险吧!</p>
|
||||||
|
<form @submit.prevent="start" v-if="!store.isLoggedIn">
|
||||||
|
<input v-model="name" type="text" placeholder="输入你的名字..." autofocus maxlength="20" class="name-input" />
|
||||||
|
<button type="submit" class="start-btn" :disabled="!name.trim()">开始冒险 →</button>
|
||||||
|
</form>
|
||||||
|
<div v-else class="returning">
|
||||||
|
<p>欢迎回来,<strong>{{ store.playerName }}</strong>!</p>
|
||||||
|
<p class="stat-line">已获得 <span class="star-count">{{ store.totalStars }}</span> 颗星星</p>
|
||||||
|
<router-link to="/levels" class="continue-btn">继续冒险 →</router-link>
|
||||||
|
<button class="logout-btn" @click="store.logout()">换个名字</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>
|
||||||
11
frontend/vite.config.js
Normal file
11
frontend/vite.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:8000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fastapi==0.115.0
|
||||||
|
uvicorn==0.30.6
|
||||||
|
aiosqlite==0.20.0
|
||||||
Reference in New Issue
Block a user