From e465b1cf71bac6dcec099a5726f53ea9b9a7590a Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Tue, 7 Apr 2026 10:17:15 +0100 Subject: [PATCH] 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. --- .gitignore | 12 + Dockerfile | 17 + Makefile | 27 + backend/database.py | 34 + backend/main.py | 119 ++ deploy/deployment.yaml | 42 + deploy/ingress.yaml | 23 + deploy/namespace.yaml | 4 + deploy/pvc.yaml | 11 + deploy/service.yaml | 11 + frontend/index.html | 15 + frontend/package-lock.json | 1197 +++++++++++++++++++++ frontend/package.json | 19 + frontend/src/App.vue | 57 + frontend/src/components/CodeEditor.vue | 106 ++ frontend/src/components/LevelComplete.vue | 106 ++ frontend/src/components/MachineState.vue | 139 +++ frontend/src/components/OutputConsole.vue | 72 ++ frontend/src/components/TutorialPanel.vue | 99 ++ frontend/src/lib/levels.js | 376 +++++++ frontend/src/lib/vm.js | 375 +++++++ frontend/src/main.js | 9 + frontend/src/router/index.js | 12 + frontend/src/stores/game.js | 84 ++ frontend/src/views/LevelSelectView.vue | 111 ++ frontend/src/views/LevelView.vue | 266 +++++ frontend/src/views/WelcomeView.vue | 158 +++ frontend/vite.config.js | 11 + requirements.txt | 3 + 29 files changed, 3515 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 backend/database.py create mode 100644 backend/main.py create mode 100644 deploy/deployment.yaml create mode 100644 deploy/ingress.yaml create mode 100644 deploy/namespace.yaml create mode 100644 deploy/pvc.yaml create mode 100644 deploy/service.yaml create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/components/CodeEditor.vue create mode 100644 frontend/src/components/LevelComplete.vue create mode 100644 frontend/src/components/MachineState.vue create mode 100644 frontend/src/components/OutputConsole.vue create mode 100644 frontend/src/components/TutorialPanel.vue create mode 100644 frontend/src/lib/levels.js create mode 100644 frontend/src/lib/vm.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/stores/game.js create mode 100644 frontend/src/views/LevelSelectView.vue create mode 100644 frontend/src/views/LevelView.vue create mode 100644 frontend/src/views/WelcomeView.vue create mode 100644 frontend/vite.config.js create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2aacd7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +node_modules/ +dist/ +__pycache__/ +*.pyc +*.pyo +*.db +*.db-journal +*.db-wal +.env +.vite/ +.DS_Store +/tmp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..42b1d92 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2331b93 --- /dev/null +++ b/Makefile @@ -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" diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..757b422 --- /dev/null +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..668724a --- /dev/null +++ b/backend/main.py @@ -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) diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml new file mode 100644 index 0000000..2abe7cc --- /dev/null +++ b/deploy/deployment.yaml @@ -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 diff --git a/deploy/ingress.yaml b/deploy/ingress.yaml new file mode 100644 index 0000000..32d4a2a --- /dev/null +++ b/deploy/ingress.yaml @@ -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 diff --git a/deploy/namespace.yaml b/deploy/namespace.yaml new file mode 100644 index 0000000..a3f3511 --- /dev/null +++ b/deploy/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: simpleasm diff --git a/deploy/pvc.yaml b/deploy/pvc.yaml new file mode 100644 index 0000000..0aee8d1 --- /dev/null +++ b/deploy/pvc.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: simpleasm-data + namespace: simpleasm +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/deploy/service.yaml b/deploy/service.yaml new file mode 100644 index 0000000..4c5dee2 --- /dev/null +++ b/deploy/service.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Service +metadata: + name: simpleasm + namespace: simpleasm +spec: + selector: + app: simpleasm + ports: + - port: 80 + targetPort: 8000 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..41c9e8d --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,15 @@ + + + + + + Simple ASM - 汇编探险家 + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..6f68680 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1197 @@ +{ + "name": "simpleasm", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simpleasm", + "version": "1.0.0", + "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" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..707e91a --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..e5e316a --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,57 @@ + + + diff --git a/frontend/src/components/CodeEditor.vue b/frontend/src/components/CodeEditor.vue new file mode 100644 index 0000000..6b30156 --- /dev/null +++ b/frontend/src/components/CodeEditor.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/components/LevelComplete.vue b/frontend/src/components/LevelComplete.vue new file mode 100644 index 0000000..d5f18a8 --- /dev/null +++ b/frontend/src/components/LevelComplete.vue @@ -0,0 +1,106 @@ + + + + + diff --git a/frontend/src/components/MachineState.vue b/frontend/src/components/MachineState.vue new file mode 100644 index 0000000..827364b --- /dev/null +++ b/frontend/src/components/MachineState.vue @@ -0,0 +1,139 @@ + + + + + diff --git a/frontend/src/components/OutputConsole.vue b/frontend/src/components/OutputConsole.vue new file mode 100644 index 0000000..63bd846 --- /dev/null +++ b/frontend/src/components/OutputConsole.vue @@ -0,0 +1,72 @@ + + + + + diff --git a/frontend/src/components/TutorialPanel.vue b/frontend/src/components/TutorialPanel.vue new file mode 100644 index 0000000..d911f93 --- /dev/null +++ b/frontend/src/components/TutorialPanel.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/frontend/src/lib/levels.js b/frontend/src/lib/levels.js new file mode 100644 index 0000000..300096f --- /dev/null +++ b/frontend/src/lib/levels.js @@ -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], + }, +] diff --git a/frontend/src/lib/vm.js b/frontend/src/lib/vm.js new file mode 100644 index 0000000..a96b9c4 --- /dev/null +++ b/frontend/src/lib/vm.js @@ -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: '通过!' } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..c2b1175 --- /dev/null +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..ddc290e --- /dev/null +++ b/frontend/src/router/index.js @@ -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, +}) diff --git a/frontend/src/stores/game.js b/frontend/src/stores/game.js new file mode 100644 index 0000000..4516653 --- /dev/null +++ b/frontend/src/stores/game.js @@ -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)) + }, + }, +}) diff --git a/frontend/src/views/LevelSelectView.vue b/frontend/src/views/LevelSelectView.vue new file mode 100644 index 0000000..ca830cf --- /dev/null +++ b/frontend/src/views/LevelSelectView.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/frontend/src/views/LevelView.vue b/frontend/src/views/LevelView.vue new file mode 100644 index 0000000..755f17b --- /dev/null +++ b/frontend/src/views/LevelView.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/frontend/src/views/WelcomeView.vue b/frontend/src/views/WelcomeView.vue new file mode 100644 index 0000000..3eca7f2 --- /dev/null +++ b/frontend/src/views/WelcomeView.vue @@ -0,0 +1,158 @@ + + + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..cbdec61 --- /dev/null +++ b/frontend/vite.config.js @@ -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' + } + } +}) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..bac91a8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn==0.30.6 +aiosqlite==0.20.0