generated from playground/template-python
미로 생성·다익스트라 탐색 시각화 앱 구현
- main.py: 기본 템플릿을 200×100 랜덤 미로 시각화 앱으로 교체. Flask는 단일 HTML 페이지를 서빙하고, 미로 생성(randomized DFS)· 다익스트라 탐색·렌더링은 브라우저 <canvas> 위 JavaScript로 처리. 속도 조절 슬라이더, 미로 재생성/탐색 버튼, 입구·출구·탐색 영역· 최단 경로 시각화 포함. 배포 규격대로 0.0.0.0:8080 유지. - README.md: 템플릿 안내에서 앱 기능·알고리즘 설명으로 갱신. - CLAUDE.md: 배포 규격(0.0.0.0:8080, requirements.txt) 규약 추가.
This commit is contained in:
11
CLAUDE.md
Normal file
11
CLAUDE.md
Normal file
@@ -0,0 +1,11 @@
|
||||
## 배포
|
||||
|
||||
1. 변경 사항을 커밋/푸시합니다.
|
||||
2. Kubero(https://kubero.bokdev.in)에서 배포하면
|
||||
`http://<내앱이름>.apps.bokdev.in` 으로 접속됩니다.
|
||||
|
||||
## 꼭 지킬 규칙 (배포 규격)
|
||||
|
||||
- HTTP 서버는 **0.0.0.0 의 8080 포트**에서 떠야 합니다.
|
||||
- 새 패키지를 쓰면 `requirements.txt`에 꼭 추가하세요.
|
||||
- 파일에 저장한 데이터는 재배포하면 사라집니다.
|
||||
36
README.md
36
README.md
@@ -1,20 +1,26 @@
|
||||
# Python(Flask) 앱 템플릿
|
||||
# 랜덤 미로 생성 & 다익스트라 탐색 시각화
|
||||
|
||||
Playground에 배포되는 Python 웹앱의 출발점입니다.
|
||||
가로 200칸 × 세로 100칸의 랜덤 미로를 생성하고, 입구(좌상단)에서 출구(우하단)까지의
|
||||
최단 경로를 다익스트라 알고리즘으로 찾아 시각적으로 보여주는 웹앱입니다.
|
||||
|
||||
## 사용법
|
||||
Flask가 단일 HTML 페이지를 서빙하고, 미로 생성·탐색·렌더링은 모두 브라우저의
|
||||
`<canvas>` 위에서 JavaScript로 동작합니다 (20,000칸을 부드럽게 애니메이션하기 위함).
|
||||
|
||||
1. 이 저장소 화면 오른쪽 위 **"이 템플릿 사용"** 버튼으로 내 앱 저장소를 만듭니다
|
||||
(소유자: **playground**, 이름: 영문 소문자, 예: `2610434-lunch-picker`).
|
||||
2. Coder 워크스페이스 터미널에서 복제합니다:
|
||||
`git clone https://gitea.bokdev.in/playground/<내앱이름>.git`
|
||||
3. Claude Code에게 원하는 앱을 만들어 달라고 하세요. 예:
|
||||
"main.py를 고쳐서 점심 메뉴 추천 앱을 만들어줘"
|
||||
4. 커밋/푸시 후 Kubero(https://kubero.bokdev.in)에서 배포하면
|
||||
`http://<내앱이름>.apps.bokdev.in` 으로 접속됩니다.
|
||||
## 기능
|
||||
|
||||
## 꼭 지킬 규칙 (배포 규격)
|
||||
- **즉시 생성**: 화면에 진입하면 미로가 바로 생성되며, 생성 과정(randomized DFS로
|
||||
통로를 파나가는 과정)을 단계적으로 빠르게 애니메이션으로 보여줍니다.
|
||||
- **입구 / 출구**: 좌상단이 입구(초록), 우하단이 출구(빨강)입니다.
|
||||
- **속도 조절 바**: 미로와 버튼 사이의 가로 슬라이더로 애니메이션 속도를 조절합니다.
|
||||
진행 중에도 즉시 반영됩니다.
|
||||
- **미로 재생성** 버튼: 새로운 랜덤 미로를 다시 생성합니다.
|
||||
- **미로 탐색** 버튼: 다익스트라 알고리즘으로 입구→출구를 탐색합니다.
|
||||
탐색 영역이 파란색으로 확장되는 과정을 애니메이션으로 보여준 뒤,
|
||||
최단 경로를 노란색으로 그립니다.
|
||||
|
||||
- HTTP 서버는 **0.0.0.0 의 8080 포트**에서 떠야 합니다.
|
||||
- 새 패키지를 쓰면 `requirements.txt`에 꼭 추가하세요 (Claude에게 부탁하면 해줍니다).
|
||||
- 파일에 저장한 데이터는 재배포하면 사라집니다.
|
||||
## 알고리즘
|
||||
|
||||
- **생성**: randomized DFS(깊이 우선 탐색 기반 백트래킹)로 모든 칸을 연결하는
|
||||
하나의 완전한 미로(spanning tree)를 만듭니다.
|
||||
- **탐색**: 모든 통로의 가중치가 1인 다익스트라 알고리즘으로 입구 칸에서 출구 칸까지의
|
||||
최단 경로를 찾습니다.
|
||||
|
||||
301
main.py
301
main.py
@@ -1,13 +1,310 @@
|
||||
# 내 앱의 시작점입니다. Claude Code에게 "이 앱을 ~하게 바꿔줘"라고 요청하세요.
|
||||
# 200x100 랜덤 미로 생성 + 다익스트라 탐색 시각화 앱
|
||||
# 규칙: 반드시 0.0.0.0:8080 에서 HTTP 서비스해야 합니다 (배포 규격).
|
||||
from flask import Flask
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
PAGE = r"""<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>랜덤 미로 (200 x 100)</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; background: #0f1115; color: #e6e6e6;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex; flex-direction: column; align-items: center;
|
||||
min-height: 100vh; padding: 16px;
|
||||
}
|
||||
h1 { font-size: 18px; font-weight: 600; margin: 4px 0 10px; }
|
||||
#wrap {
|
||||
width: 100%; max-width: 1220px; overflow-x: auto;
|
||||
border: 1px solid #2a2f3a; border-radius: 8px; background: #11151c;
|
||||
padding: 6px; line-height: 0;
|
||||
}
|
||||
canvas { image-rendering: pixelated; width: 100%; height: auto; display: block; }
|
||||
#speedbar {
|
||||
width: 100%; max-width: 1220px; display: flex; align-items: center; gap: 12px;
|
||||
margin: 14px 0 2px; font-size: 12px; color: #9aa4b2;
|
||||
}
|
||||
#speedbar label { white-space: nowrap; }
|
||||
#speed { flex: 1 1 auto; height: 6px; cursor: pointer; accent-color: #19c37d; }
|
||||
#controls { display: flex; gap: 10px; margin: 12px 0 6px; flex-wrap: wrap; justify-content: center; }
|
||||
button {
|
||||
font-size: 14px; padding: 9px 18px; border-radius: 8px; border: 1px solid #3a4152;
|
||||
background: #1b2230; color: #e6e6e6; cursor: pointer; transition: background .12s, opacity .12s;
|
||||
}
|
||||
button:hover:not(:disabled) { background: #28324a; }
|
||||
button:disabled { opacity: .45; cursor: default; }
|
||||
#status { font-size: 13px; color: #9aa4b2; min-height: 18px; }
|
||||
.legend { display: flex; gap: 16px; font-size: 12px; color: #9aa4b2; margin-top: 4px; }
|
||||
.legend i { display: inline-block; width: 11px; height: 11px; border-radius: 2px; margin-right: 5px; vertical-align: -1px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>랜덤 미로 200 × 100 · 다익스트라 탐색</h1>
|
||||
<div id="wrap"><canvas id="maze"></canvas></div>
|
||||
<div id="speedbar">
|
||||
<label for="speed">속도 느림</label>
|
||||
<input id="speed" type="range" min="1" max="100" value="40">
|
||||
<label for="speed">빠름</label>
|
||||
</div>
|
||||
<div id="controls">
|
||||
<button id="regen">미로 재생성</button>
|
||||
<button id="solve">미로 탐색</button>
|
||||
</div>
|
||||
<div id="status"></div>
|
||||
<div class="legend">
|
||||
<span><i style="background:#19c37d"></i>입구</span>
|
||||
<span><i style="background:#ff5d5d"></i>출구</span>
|
||||
<span><i style="background:#3d6bd6"></i>탐색 영역</span>
|
||||
<span><i style="background:#ffd60a"></i>최단 경로</span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const COLS = 200, ROWS = 100; // 미로 칸 수 (가로 x 세로)
|
||||
const SCALE = 3; // 미로-격자 한 점의 픽셀 크기
|
||||
const GW = COLS * 2 + 1, GH = ROWS * 2 + 1; // 벽 포함 격자 크기
|
||||
|
||||
// 색상
|
||||
const C_WALL = "#0c0e13", C_PASS = "#e9edf4";
|
||||
const C_VISIT = "#3d6bd6", C_PATH = "#ffd60a";
|
||||
const C_START = "#19c37d", C_EXIT = "#ff5d5d";
|
||||
|
||||
const canvas = document.getElementById("maze");
|
||||
canvas.width = GW * SCALE;
|
||||
canvas.height = GH * SCALE;
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
const statusEl = document.getElementById("status");
|
||||
const regenBtn = document.getElementById("regen");
|
||||
const solveBtn = document.getElementById("solve");
|
||||
const speedEl = document.getElementById("speed");
|
||||
|
||||
// 슬라이더(1~100) -> 프레임당 처리량 배율. 슬라이더는 진행 중에도 즉시 반영됨.
|
||||
function speedFactor() {
|
||||
const v = +speedEl.value; // 1 ~ 100
|
||||
return 0.04 + (v / 100) * (v / 100) * 2.0; // 느릴 때 더 촘촘하게 보이도록 곡선 적용
|
||||
}
|
||||
function budget(base) { return Math.max(1, Math.round(base * speedFactor())); }
|
||||
|
||||
let walls; // walls[r][c] : 비트마스크 (N=1,E=2,S=4,W=8) -> 벽이 있으면 1
|
||||
let animId = null; // 현재 진행중인 애니메이션 프레임 id
|
||||
|
||||
// ---------- 저수준 그리기 ----------
|
||||
function gx(gxv, gyv, color) { // 격자 한 점 채우기
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(gxv * SCALE, gyv * SCALE, SCALE, SCALE);
|
||||
}
|
||||
function cellPoint(c, r) { return [c * 2 + 1, r * 2 + 1]; } // 칸 중심의 격자 좌표
|
||||
|
||||
function paintCell(c, r, color) { // 칸(중심) 색칠
|
||||
gx(c * 2 + 1, r * 2 + 1, color);
|
||||
}
|
||||
function paintEdge(c1, r1, c2, r2, color) { // 인접한 두 칸 사이 통로 색칠
|
||||
gx(c1 + c2 + 1, r1 + r2 + 1, color);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
ctx.fillStyle = C_WALL;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
// 미로 전체를 (현재 walls 상태대로) 다시 그림
|
||||
function drawMaze() {
|
||||
clearAll();
|
||||
for (let r = 0; r < ROWS; r++) {
|
||||
for (let c = 0; c < COLS; c++) {
|
||||
paintCell(c, r, C_PASS);
|
||||
const w = walls[r][c];
|
||||
if (!(w & 2) && c + 1 < COLS) paintEdge(c, r, c + 1, r, C_PASS); // 동쪽 통로
|
||||
if (!(w & 4) && r + 1 < ROWS) paintEdge(c, r, c, r + 1, C_PASS); // 남쪽 통로
|
||||
}
|
||||
}
|
||||
openEndpoints();
|
||||
}
|
||||
|
||||
// 입구(좌상단 위쪽 테두리) / 출구(우하단 아래쪽 테두리) 표시
|
||||
function openEndpoints() {
|
||||
const [sx, sy] = cellPoint(0, 0);
|
||||
gx(sx, sy - 1, C_START); gx(sx, sy, C_START);
|
||||
const [ex, ey] = cellPoint(COLS - 1, ROWS - 1);
|
||||
gx(ex, ey + 1, C_EXIT); gx(ex, ey, C_EXIT);
|
||||
}
|
||||
|
||||
// ---------- 미로 생성 (randomized DFS) ----------
|
||||
// 생성 과정을 단계적으로 시각화하면서 carve 한다.
|
||||
function generate() {
|
||||
cancelAnim();
|
||||
walls = Array.from({length: ROWS}, () => new Uint8Array(COLS).fill(15)); // 모든 벽 존재
|
||||
const visited = Array.from({length: ROWS}, () => new Uint8Array(COLS));
|
||||
clearAll();
|
||||
|
||||
const stack = [[0, 0]];
|
||||
visited[0][0] = 1;
|
||||
paintCell(0, 0, C_PASS);
|
||||
|
||||
const DIRS = [
|
||||
[0, -1, 1, 4], // N : 현재 N벽 제거(1), 이웃 S벽 제거(4)
|
||||
[1, 0, 2, 8], // E
|
||||
[0, 1, 4, 1], // S
|
||||
[-1, 0, 8, 2], // W
|
||||
];
|
||||
|
||||
setBusy(true, "미로 생성 중…");
|
||||
|
||||
function step(budget) {
|
||||
while (budget-- > 0 && stack.length) {
|
||||
const [c, r] = stack[stack.length - 1];
|
||||
// 방문하지 않은 이웃 수집
|
||||
const opts = [];
|
||||
for (const d of DIRS) {
|
||||
const nc = c + d[0], nr = r + d[1];
|
||||
if (nc >= 0 && nc < COLS && nr >= 0 && nr < ROWS && !visited[nr][nc]) opts.push(d);
|
||||
}
|
||||
if (opts.length === 0) { stack.pop(); continue; }
|
||||
const d = opts[(Math.random() * opts.length) | 0];
|
||||
const nc = c + d[0], nr = r + d[1];
|
||||
walls[r][c] &= ~d[2]; // 현재 칸 벽 제거
|
||||
walls[nr][nc] &= ~d[3]; // 이웃 칸 벽 제거
|
||||
visited[nr][nc] = 1;
|
||||
paintCell(nc, nr, C_PASS);
|
||||
paintEdge(c, r, nc, nr, C_PASS);
|
||||
stack.push([nc, nr]);
|
||||
}
|
||||
}
|
||||
|
||||
function frame() {
|
||||
step(budget(900)); // 프레임당 단계 수 (슬라이더로 조절)
|
||||
if (stack.length) {
|
||||
animId = requestAnimationFrame(frame);
|
||||
} else {
|
||||
animId = null;
|
||||
openEndpoints();
|
||||
setBusy(false, "미로 생성 완료. ‘미로 탐색’을 눌러보세요.");
|
||||
}
|
||||
}
|
||||
frame();
|
||||
}
|
||||
|
||||
// ---------- 다익스트라 탐색 ----------
|
||||
// 모든 통로 가중치 1. 입구 칸 -> 출구 칸 최단 경로.
|
||||
function solve() {
|
||||
cancelAnim();
|
||||
drawMaze(); // 이전 탐색 흔적 제거
|
||||
setBusy(true, "다익스트라 탐색 중…");
|
||||
|
||||
const N = COLS * ROWS;
|
||||
const dist = new Int32Array(N).fill(-1);
|
||||
const prev = new Int32Array(N).fill(-1);
|
||||
const idx = (c, r) => r * COLS + c;
|
||||
|
||||
// 가중치가 모두 1이므로 거리순 처리는 단순 FIFO 큐로 충분 (다익스트라 = BFS).
|
||||
const start = idx(0, 0), goal = idx(COLS - 1, ROWS - 1);
|
||||
let queue = [start];
|
||||
dist[start] = 0;
|
||||
|
||||
function neighbors(c, r) {
|
||||
const w = walls[r][c], res = [];
|
||||
if (!(w & 1)) res.push([c, r - 1]);
|
||||
if (!(w & 2)) res.push([c + 1, r]);
|
||||
if (!(w & 4)) res.push([c, r + 1]);
|
||||
if (!(w & 8)) res.push([c - 1, r]);
|
||||
return res;
|
||||
}
|
||||
|
||||
let found = false;
|
||||
function expand(budget) {
|
||||
while (budget-- > 0 && qHead < queue.length) {
|
||||
const cur = queue[qHead++];
|
||||
if (cur === goal) { found = true; return; }
|
||||
const c = cur % COLS, r = (cur / COLS) | 0;
|
||||
for (const [nc, nr] of neighbors(c, r)) {
|
||||
const ni = idx(nc, nr);
|
||||
if (dist[ni] === -1) {
|
||||
dist[ni] = dist[cur] + 1;
|
||||
prev[ni] = cur;
|
||||
paintCell(nc, nr, C_VISIT);
|
||||
paintEdge(c, r, nc, nr, C_VISIT);
|
||||
queue.push(ni);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let qHead = 0;
|
||||
function frame() {
|
||||
expand(budget(1400));
|
||||
openEndpoints();
|
||||
if (found || qHead >= queue.length) {
|
||||
animId = null;
|
||||
if (found) drawPath(prev, start, goal, idx);
|
||||
else setBusy(false, "경로를 찾지 못했습니다.");
|
||||
} else {
|
||||
animId = requestAnimationFrame(frame);
|
||||
}
|
||||
}
|
||||
frame();
|
||||
}
|
||||
|
||||
// 역추적하여 노란색 최단 경로를 애니메이션으로 그림
|
||||
function drawPath(prev, start, goal, idx) {
|
||||
const path = [];
|
||||
for (let v = goal; v !== -1; v = prev[v]) { path.push(v); if (v === start) break; }
|
||||
path.reverse();
|
||||
setBusy(true, "최단 경로 표시 중…");
|
||||
|
||||
let i = 0;
|
||||
function frame() {
|
||||
let bud = budget(120);
|
||||
while (bud-- > 0 && i < path.length) {
|
||||
const cur = path[i];
|
||||
const c = cur % COLS, r = (cur / COLS) | 0;
|
||||
paintCell(c, r, C_PATH);
|
||||
if (i > 0) {
|
||||
const p = path[i - 1];
|
||||
paintEdge(p % COLS, (p / COLS) | 0, c, r, C_PATH);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
openEndpoints();
|
||||
if (i < path.length) {
|
||||
animId = requestAnimationFrame(frame);
|
||||
} else {
|
||||
animId = null;
|
||||
setBusy(false, "탐색 완료! 경로 길이 " + path.length + " 칸.");
|
||||
}
|
||||
}
|
||||
frame();
|
||||
}
|
||||
|
||||
// ---------- 공통 ----------
|
||||
function cancelAnim() {
|
||||
if (animId !== null) { cancelAnimationFrame(animId); animId = null; }
|
||||
}
|
||||
function setBusy(busy, msg) {
|
||||
regenBtn.disabled = busy;
|
||||
solveBtn.disabled = busy;
|
||||
if (msg !== undefined) statusEl.textContent = msg;
|
||||
}
|
||||
|
||||
regenBtn.addEventListener("click", generate);
|
||||
solveBtn.addEventListener("click", solve);
|
||||
|
||||
// 화면 진입 즉시 미로 생성
|
||||
generate();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
return "<h1>안녕하세요! 나의 첫 Playground 앱입니다 🎉</h1>"
|
||||
return PAGE
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user