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) 규약 추가.
312 lines
10 KiB
Python
312 lines
10 KiB
Python
# 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 PAGE
|
||
|
||
|
||
if __name__ == "__main__":
|
||
app.run(host="0.0.0.0", port=8080)
|