Files
2610361-maze-runner/main.py
2610361 a26e6b0ace Coolify 배포 문서 정렬 및 PORT 환경변수 지원
- main.py: 포트를 PORT 환경변수(기본 8080)에서 읽도록 변경. host는 0.0.0.0 유지.
- Dockerfile: ENV PORT=8080 / EXPOSE 8080, 주석을 Coolify 배포 기준으로 갱신.
- CLAUDE.md: 배포 안내를 Coolify(포트 8080) 기준으로 갱신.
2026-06-16 18:09:41 +09:00

316 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 200x100 랜덤 미로 생성 + 다익스트라 탐색 시각화 앱
# 배포 규격: 0.0.0.0 에서 HTTP 서비스. 포트는 PORT 환경변수(기본 8080)를 따른다.
# (Coolify 앱 설정의 Port 도 8080 으로 맞춘다)
import os
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>랜덤 미로 &nbsp;200 × 100 &nbsp;·&nbsp; 다익스트라 탐색</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__":
port = int(os.environ.get("PORT", "8080"))
app.run(host="0.0.0.0", port=port)