실습 워크벤치
바이브코딩으로 만드는 나만의 게임과 학습 도구 · 프롬프트와 완성 코드를 한곳에
먼저 — 캔버스를 켜세요
이 수업은 제미나이의 캔버스에서 진행합니다. 켜 두면 만든 화면이 오른쪽에 바로 뜹니다.

그다음 — 내 주소 만들기 (1차시에 한 번만)
계정과 저장소는 처음 한 번만 만듭니다. 그 뒤로는 파일을 올리기만 하면 됩니다.
github.com → Sign up. 아이디가 그대로 주소가 됩니다. 영문 소문자로 짧게.my-web처럼 영문으로, Public으로.내아이디.github.io/my-web/이 열립니다.
매 차시 끝 — 내 저장소에 올리기
1차시부터 6차시까지 매번 같은 네 단계입니다. 익숙해지면 1분이면 됩니다.
.html. 첫 페이지만 index.html.같은 이름으로 다시 올리면 덮어쓰기가 됩니다. 고친 파일은 같은 이름으로 올리세요. 올린 직후 404가 뜨면 고장이 아니라 아직 올라가는 중입니다.
1차시 · AI와 대화하여 웹페이지 제작하기
말로 설명했더니 화면이 생겼다
웹페이지를 하나 만들어 줘. "나를 소개하는 페이지"야.
[목표와 환경]
- HTML, CSS, 자바스크립트를 파일 하나에 모두 넣어 줘.
- 인터넷에서 불러오는 글꼴이나 라이브러리는 쓰지 마.
[디자인]
- 맨 위에 큰 제목으로 내 별명, 그 아래 한 줄 소개.
- 그 아래에 카드 세 개: 좋아하는 것 / 요즘 관심사 / 올해 해 보고 싶은 것.
- 색은 밝고 시원한 느낌으로, 글자는 크게.
- 휴대폰 화면에서도 가로로 밀리지 않게 만들어 줘.
[동작]
- 카드를 누르면 숨어 있던 설명 한 줄이 펼쳐지고, 다시 누르면 접혀야 해.
[데이터]
- 따로 저장하는 기능은 없어도 돼.
내용은 일단 예시로 채워 줘. 내가 나중에 직접 고칠게.캔버스를 켜는 걸 잊지 마세요. 입력창 아래의 캔버스 버튼을 눌러 두면 만든 화면이 오른쪽에 바로 뜹니다. 안 켜면 코드만 길게 나와서 뭘 만들었는지 보이지 않습니다.
첫 페이지는 반드시 index.html
이 이름이어야 내아이디.github.io/내저장소/를 눌렀을 때 바로 열립니다. 다른 이름이면 주소 뒤에 파일 이름을 붙여야 열립니다.
올리는 순서
① 캔버스에서 코드 받기 → ② 이름을 index.html로 → ③ 저장소에서 Add file → Upload files → ④ 아래 Commit changes
1~2분 기다리세요. 올린 직후에는 주소가 404로 나올 수 있습니다. 고장이 아니라 아직 올라가는 중입니다.
아이디는 신중히. 깃허브 아이디가 그대로 주소가 됩니다. 실명 대신 영문 별명으로 짧게 정하세요.
index.html으로 바꿔 내 저장소에 업로드 → Commit → 내주소/index.html으로 열어 보기올리는 법2차시 · 게임 로직 설계하고 구현하기
내가 정한 규칙으로 만드는 내 게임
웹게임을 하나 만들어 줘.
[만들 게임]
여기에 내가 만들 게임을 3~4줄로 설명해.
예) 30초 동안 3x3 칸에서 두더지가 튀어나오면 누르는 게임.
두더지를 누르면 점수가 오르고, 가끔 폭탄이 나오는데 그걸 누르면 점수가 깎여.
[반드시 지킬 규격]
- HTML, CSS, 자바스크립트를 파일 하나에 모두 넣어 줘. 외부 라이브러리는 쓰지 마.
- 한 판은 3분 안에 끝나야 해.
- 결과는 숫자 하나(점수)로 나오고, 높을수록 잘한 거야.
- 코드 맨 위에 const GAME_ID = 'my-game'; 를 만들어 줘.
- 게임이 끝나면 gameOver(점수) 함수를 부르고, 그 함수 안에서 점수를 화면에 보여 줘.
- 혼자 하는 게임이고, 마우스나 손가락 터치만 써.
[디자인]
- 화면 맨 위에 남은 시간과 현재 점수를 크게.
- 글자는 크게, 휴대폰에서도 가로 스크롤 없이 보이게.
- 버튼을 누르면 눌렸다는 게 바로 보이게 해 줘.
[데이터]
- 아직 저장 기능은 넣지 마. 다음 시간에 붙일 거야.무엇을 만들지 모르겠다면
골라도 되고, 바꿔도 되고, 무시해도 됩니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#5B3DF5">
<title>반응속도 · 5번의 찰나</title>
<style>
:root{--accent:#5B3DF5;--ink:#171A2E;--muted:#62648A;--line:#DCDCE8;--bg:#F3F3F8}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:-apple-system,"Malgun Gothic","Apple SD Gothic Neo",sans-serif;
display:flex;flex-direction:column;align-items:center;padding:20px 14px 60px}
h1{font-size:26px;margin:6px 0 2px}
.sub{color:var(--muted);font-size:15px;margin:0 0 18px;text-align:center}
.board{width:100%;max-width:460px}
.pad{height:280px;border-radius:10px;display:flex;flex-direction:column;
align-items:center;justify-content:center;cursor:pointer;user-select:none;
background:#353A5E;color:#fff;text-align:center;padding:16px;
transition:background .08s linear;border:0;width:100%;font-family:inherit}
.pad b{font-size:26px;display:block;margin-bottom:6px}
.pad span{font-size:16px;opacity:.85;line-height:1.6}
.pad.wait{background:#D9435A}
.pad.go{background:#15803d}
.pad.early{background:#8a5a00}
.pad:focus-visible{outline:3px solid var(--accent);outline-offset:3px}
.rounds{display:flex;gap:6px;justify-content:center;margin:14px 0}
.dot{width:38px;height:38px;border-radius:9px;background:#E4E4EF;border:1px solid var(--line);
display:flex;align-items:center;justify-content:center;font-size:12.5px;color:var(--muted)}
.dot.on{background:var(--accent);color:#fff;border-color:var(--accent)}
.panel{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin-top:16px}
.score{font-size:42px;font-weight:700;color:var(--accent);line-height:1.1}
.label{color:var(--muted);font-size:14px}
.row{display:flex;justify-content:space-between;align-items:baseline;gap:12px;
padding:7px 0;border-bottom:1px dashed var(--line)}
.row:last-child{border:0}
button.go{background:var(--accent);color:#fff;border:0;border-radius:10px;padding:12px 22px;
font-size:16px;cursor:pointer;font-family:inherit;margin-top:12px;width:100%}
.note{color:var(--muted);font-size:13px;margin-top:18px;text-align:center;max-width:460px;line-height:1.6}
</style>
</head>
<body>
<h1>5번의 찰나</h1>
<p class="sub">초록으로 바뀌는 순간 누르세요. 다섯 번의 평균으로 점수가 정해집니다.</p>
<div class="board">
<button class="pad" id="pad"><b>누르면 시작</b><span>빨강일 때 기다리고<br>초록이 되면 바로 누르세요</span></button>
<div class="rounds" id="rounds"></div>
<div class="panel">
<div class="label">점수</div>
<div class="score" id="score">—</div>
<div id="detail"></div>
<div class="row"><span>최고 점수</span><b id="best">0</b></div>
<button class="go" id="again" style="display:none">다시 하기</button>
</div>
</div>
<p class="note">기록은 이 브라우저에만 저장됩니다. 다른 컴퓨터에서 열면 처음부터 시작합니다.</p>
<script>
/* =========================================================
게임 규격 — 이 네 줄은 어떤 게임을 만들어도 똑같이 지킵니다
========================================================= */
const GAME_ID = 'reaction';
const GAME_NAME = '반응속도';
// 게임이 끝나면 gameOver(점수) 를 부릅니다. 점수는 높을수록 잘한 것.
/* ===== 점수 저장 모듈 (3차시) =====
localStorage = 이 브라우저에만 남는 저장소 */
function saveScore(score){
const key = 'best_' + GAME_ID;
const best = Number(localStorage.getItem(key) || 0);
if (score > best) localStorage.setItem(key, String(score));
return Math.max(score, best);
}
function loadBest(){
return Number(localStorage.getItem('best_' + GAME_ID) || 0);
}
/* ===== 게임 본체 ===== */
const TOTAL = 5;
const pad = document.getElementById('pad');
const roundsEl = document.getElementById('rounds');
const scoreEl = document.getElementById('score');
const detailEl = document.getElementById('detail');
const bestEl = document.getElementById('best');
const againBtn = document.getElementById('again');
let times = [], state = 'idle', timer = null, startAt = 0;
function drawRounds(){
roundsEl.innerHTML = '';
for (let i = 0; i < TOTAL; i++){
const d = document.createElement('div');
d.className = 'dot' + (times[i] !== undefined ? ' on' : '');
d.textContent = times[i] !== undefined ? times[i] : (i + 1);
roundsEl.appendChild(d);
}
}
function setPad(cls, title, desc){
pad.className = 'pad' + (cls ? ' ' + cls : '');
pad.innerHTML = '<b>' + title + '</b><span>' + desc + '</span>';
}
function nextRound(){
if (times.length >= TOTAL){ finish(); return; }
state = 'wait';
setPad('wait', '기다리세요', '초록이 될 때까지 누르면 안 됩니다');
const delay = 1200 + Math.random() * 2600;
timer = setTimeout(function(){
state = 'go';
startAt = performance.now();
setPad('go', '지금!', '누르세요');
}, delay);
}
function finish(){
state = 'done';
const avg = Math.round(times.reduce(function(a,b){return a+b;}, 0) / times.length);
const score = Math.max(0, 1000 - avg);
setPad('', '끝!', '평균 ' + avg + '밀리초');
detailEl.innerHTML =
'<div class="row"><span>평균 반응속도</span><b>' + avg + ' ms</b></div>' +
'<div class="row"><span>가장 빨랐던 판</span><b>' + Math.min.apply(null, times) + ' ms</b></div>';
gameOver(score);
againBtn.style.display = 'block';
}
function gameOver(score){
scoreEl.textContent = score;
const best = saveScore(score);
bestEl.textContent = best;
}
pad.addEventListener('click', function(){
if (state === 'idle' || state === 'done'){
times = []; drawRounds();
scoreEl.textContent = '—'; detailEl.innerHTML = '';
againBtn.style.display = 'none';
nextRound();
return;
}
if (state === 'wait'){
clearTimeout(timer);
state = 'early';
setPad('early', '너무 빨랐어요', '초록이 될 때까지 기다려야 합니다<br>눌러서 이 판 다시 하기');
setTimeout(function(){ if (state === 'early') state = 'ready'; }, 400);
return;
}
if (state === 'ready'){ nextRound(); return; }
if (state === 'go'){
const ms = Math.round(performance.now() - startAt);
times.push(ms);
drawRounds();
if (times.length >= TOTAL){ finish(); }
else { setPad('', ms + ' ms', '눌러서 다음 판으로'); state = 'ready'; }
}
});
againBtn.addEventListener('click', function(){ pad.click(); });
drawRounds();
bestEl.textContent = loadBest();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#5B3DF5">
<title>숫자 맞히기 · 여덟 번의 질문</title>
<style>
:root{--accent:#5B3DF5;--ink:#171A2E;--muted:#62648A;--line:#DCDCE8;--bg:#F3F3F8;
--up:#1d4ed8;--down:#D9435A;--hit:#15803d}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:-apple-system,"Malgun Gothic","Apple SD Gothic Neo",sans-serif;
display:flex;flex-direction:column;align-items:center;padding:20px 14px 60px}
h1{font-size:26px;margin:6px 0 2px}
.sub{color:var(--muted);font-size:15px;margin:0 0 18px;text-align:center}
.board{width:100%;max-width:440px}
.range{background:#fff;border:1px solid var(--line);border-radius:10px;padding:18px;text-align:center}
.range .big{font-size:34px;font-weight:700;letter-spacing:-1px}
.range .big i{font-style:normal;color:var(--accent)}
.range .lbl{color:var(--muted);font-size:14px;margin-bottom:4px}
.bar{height:10px;border-radius:5px;background:#E4E4EF;margin:14px 0 4px;position:relative;overflow:hidden}
.bar i{position:absolute;top:0;bottom:0;background:var(--accent);opacity:.35;display:block}
form{display:flex;gap:8px;margin-top:14px}
input[type=number]{flex:1;padding:14px;font-size:20px;text-align:center;border:1px solid var(--line);
border-radius:10px;font-family:inherit;background:#fff;-moz-appearance:textfield}
input::-webkit-outer-spin-button,input::-webkit-inner-spin-button{-webkit-appearance:none;margin:0}
button{background:var(--accent);color:#fff;border:0;border-radius:10px;padding:0 20px;
font-size:16px;cursor:pointer;font-family:inherit}
button:disabled{background:#AAABC6;cursor:default}
.log{margin-top:14px;display:flex;flex-direction:column;gap:6px}
.try{display:flex;justify-content:space-between;align-items:center;background:#fff;
border:1px solid var(--line);border-radius:10px;padding:10px 14px;font-size:15px}
.try b{font-size:19px}
.try .up{color:var(--up)} .try .down{color:var(--down)} .try .hit{color:var(--hit);font-weight:700}
.panel{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin-top:16px}
.score{font-size:42px;font-weight:700;color:var(--accent);line-height:1.1}
.label{color:var(--muted);font-size:14px}
.row{display:flex;justify-content:space-between;align-items:baseline;padding:7px 0;
border-bottom:1px dashed var(--line)}
.row:last-child{border:0}
.again{width:100%;padding:12px;margin-top:12px}
.note{color:var(--muted);font-size:13px;margin-top:18px;text-align:center;max-width:440px;line-height:1.6}
:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
nav :focus-visible{outline-color:#FFD400}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<h1>여덟 번의 질문</h1>
<p class="sub">1부터 100까지 중 하나. 여덟 번 안에 찾으면 이깁니다.</p>
<div class="board">
<div class="range">
<div class="lbl">남은 범위</div>
<div class="big"><i id="lo">1</i> 부터 <i id="hi">100</i> 까지</div>
<div class="bar"><i id="barfill"></i></div>
<div class="lbl" id="left">남은 기회 8번</div>
<form id="f">
<input type="number" id="guess" min="1" max="100" placeholder="숫자" autocomplete="off">
<button type="submit" id="submit">확인</button>
</form>
</div>
<div class="log" id="log"></div>
<div class="panel">
<div class="label">점수</div>
<div class="score" id="score">—</div>
<div id="detail"></div>
<div class="row"><span>최고 점수</span><b id="best">0</b></div>
<button class="again" id="again" style="display:none">새 숫자로 다시</button>
</div>
</div>
<p class="note">기록은 이 브라우저에만 저장됩니다. 다른 컴퓨터에서 열면 처음부터 시작합니다.</p>
<script>
/* ===== 게임 규격 ===== */
const GAME_ID = 'number';
const GAME_NAME = '숫자 맞히기';
/* ===== 점수 저장 모듈 (3차시) ===== */
function saveScore(score){
const key = 'best_' + GAME_ID;
const best = Number(localStorage.getItem(key) || 0);
if (score > best) localStorage.setItem(key, String(score));
return Math.max(score, best);
}
function loadBest(){ return Number(localStorage.getItem('best_' + GAME_ID) || 0); }
/* ===== 게임 본체 ===== */
const MAX_TRY = 8;
const el = function(id){ return document.getElementById(id); };
let answer, lo, hi, tries, over;
function start(){
answer = Math.floor(Math.random() * 100) + 1;
lo = 1; hi = 100; tries = 0; over = false;
el('log').innerHTML = '';
el('score').textContent = '—';
el('detail').innerHTML = '';
el('again').style.display = 'none';
el('guess').disabled = false;
el('submit').disabled = false;
el('guess').value = '';
paint();
el('guess').focus();
}
function paint(){
el('lo').textContent = lo;
el('hi').textContent = hi;
el('left').textContent = over ? '끝' : '남은 기회 ' + (MAX_TRY - tries) + '번';
const f = el('barfill');
f.style.left = ((lo - 1) / 100 * 100) + '%';
f.style.width = ((hi - lo + 1) / 100 * 100) + '%';
}
function addLog(n, kind, text){
const d = document.createElement('div');
d.className = 'try';
d.innerHTML = '<b>' + n + '</b><span class="' + kind + '">' + text + '</span>';
el('log').prepend(d);
}
function finish(win){
over = true;
el('guess').disabled = true;
el('submit').disabled = true;
el('again').style.display = 'block';
paint();
let score = 0;
if (win){
score = (MAX_TRY - tries + 1) * 120 + 40;
el('detail').innerHTML =
'<div class="row"><span>맞힌 숫자</span><b>' + answer + '</b></div>' +
'<div class="row"><span>사용한 기회</span><b>' + tries + '번</b></div>';
} else {
el('detail').innerHTML =
'<div class="row"><span>정답이었던 숫자</span><b>' + answer + '</b></div>' +
'<div class="row"><span>결과</span><b>기회를 다 썼어요</b></div>';
}
gameOver(score);
}
function gameOver(score){
el('score').textContent = score;
el('best').textContent = saveScore(score);
}
el('f').addEventListener('submit', function(e){
e.preventDefault();
if (over) return;
const n = Number(el('guess').value);
if (!n || n < 1 || n > 100){ el('guess').value = ''; return; }
tries++;
if (n === answer){ addLog(n, 'hit', '정답!'); finish(true); return; }
if (n < answer){ addLog(n, 'up', '더 큰 수'); lo = Math.max(lo, n + 1); }
else { addLog(n, 'down', '더 작은 수'); hi = Math.min(hi, n - 1); }
el('guess').value = '';
if (tries >= MAX_TRY){ finish(false); return; }
paint();
el('guess').focus();
});
el('again').addEventListener('click', start);
el('best').textContent = loadBest();
start();
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#5B3DF5">
<title>두더지 잡기 · 30초</title>
<style>
:root{--accent:#5B3DF5;--ink:#171A2E;--muted:#62648A;--line:#DCDCE8;--bg:#F3F3F8;--bad:#D9435A}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:-apple-system,"Malgun Gothic","Apple SD Gothic Neo",sans-serif;
display:flex;flex-direction:column;align-items:center;padding:20px 14px 60px}
h1{font-size:26px;margin:6px 0 2px}
.sub{color:var(--muted);font-size:15px;margin:0 0 16px;text-align:center}
.board{width:100%;max-width:420px}
.hud{display:flex;justify-content:space-between;background:#fff;border:1px solid var(--line);
border-radius:12px;padding:12px 16px;margin-bottom:12px}
.hud div{text-align:center;flex:1}
.hud .l{color:var(--muted);font-size:13px}
.hud .v{font-size:25px;font-weight:700;line-height:1.2}
.hud .v.low{color:var(--bad)}
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:10px}
.hole{aspect-ratio:1;border-radius:10px;background:#E4E4EF;border:1px solid var(--line);
display:flex;align-items:center;justify-content:center;font-size:40px;cursor:pointer;
user-select:none;padding:0;font-family:inherit;transition:background .1s}
.hole:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
.hole.up{background:#DCD5FA}
.hole.bomb{background:#FADCE0}
.hole.hit{background:var(--accent)}
.hole.miss{background:#F3CBD2}
.panel{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin-top:16px}
.score{font-size:42px;font-weight:700;color:var(--accent);line-height:1.1}
.label{color:var(--muted);font-size:14px}
.row{display:flex;justify-content:space-between;align-items:baseline;padding:7px 0;
border-bottom:1px dashed var(--line)}
.row:last-child{border:0}
button.go{background:var(--accent);color:#fff;border:0;border-radius:10px;padding:13px;
font-size:16px;cursor:pointer;font-family:inherit;width:100%;margin-top:12px}
.note{color:var(--muted);font-size:13px;margin-top:18px;text-align:center;max-width:420px;line-height:1.6}
</style>
</head>
<body>
<h1>30초 두더지</h1>
<p class="sub">두더지는 +10점, 폭탄은 −15점. 폭탄을 참는 게 실력입니다.</p>
<div class="board">
<div class="hud">
<div><div class="l">남은 시간</div><div class="v" id="time">30</div></div>
<div><div class="l">현재 점수</div><div class="v" id="live">0</div></div>
<div><div class="l">연속</div><div class="v" id="combo">0</div></div>
</div>
<div class="grid" id="grid"></div>
<div class="panel">
<div class="label">점수</div>
<div class="score" id="score">—</div>
<div id="detail"></div>
<div class="row"><span>최고 점수</span><b id="best">0</b></div>
<button class="go" id="start">시작하기</button>
</div>
</div>
<p class="note">기록은 이 브라우저에만 저장됩니다. 다른 컴퓨터에서 열면 처음부터 시작합니다.</p>
<script>
/* ===== 게임 규격 ===== */
const GAME_ID = 'mole';
const GAME_NAME = '두더지 잡기';
/* ===== 점수 저장 모듈 (3차시) ===== */
function saveScore(score){
const key = 'best_' + GAME_ID;
const best = Number(localStorage.getItem(key) || 0);
if (score > best) localStorage.setItem(key, String(score));
return Math.max(score, best);
}
function loadBest(){ return Number(localStorage.getItem('best_' + GAME_ID) || 0); }
/* ===== 게임 본체 ===== */
const SIZE = 9, PLAY_SEC = 30;
const el = function(id){ return document.getElementById(id); };
const grid = el('grid');
const holes = [];
let live = 0, combo = 0, maxCombo = 0, hits = 0, bombs = 0;
let running = false, tickTimer = null, popTimer = null, left = PLAY_SEC;
for (let i = 0; i < SIZE; i++){
const b = document.createElement('button');
b.className = 'hole';
b.dataset.kind = '';
b.addEventListener('click', function(){ whack(b); });
grid.appendChild(b);
holes.push(b);
}
function clearHole(b){
b.dataset.kind = '';
b.textContent = '';
b.className = 'hole';
}
function flash(b, cls){
b.className = 'hole ' + cls;
setTimeout(function(){ if (b.dataset.kind === '') b.className = 'hole'; }, 160);
}
function pop(){
if (!running) return;
const free = holes.filter(function(h){ return h.dataset.kind === ''; });
if (free.length){
const b = free[Math.floor(Math.random() * free.length)];
const isBomb = Math.random() < 0.25;
b.dataset.kind = isBomb ? 'bomb' : 'mole';
b.textContent = isBomb ? '💣' : '🐹';
b.classList.add(isBomb ? 'bomb' : 'up');
const life = 700 + Math.random() * 600;
setTimeout(function(){
if (b.dataset.kind === 'mole'){ combo = 0; el('combo').textContent = 0; }
if (b.dataset.kind !== '') clearHole(b);
}, life);
}
const gap = Math.max(260, 760 - (PLAY_SEC - left) * 14);
popTimer = setTimeout(pop, gap);
}
function whack(b){
if (!running) return;
const kind = b.dataset.kind;
if (kind === 'mole'){
hits++; combo++; maxCombo = Math.max(maxCombo, combo);
live += 10 + Math.min(combo - 1, 5) * 2;
clearHole(b); flash(b, 'hit');
} else if (kind === 'bomb'){
bombs++; combo = 0;
live = Math.max(0, live - 15);
clearHole(b); flash(b, 'miss');
} else {
combo = 0;
flash(b, 'miss');
}
el('live').textContent = live;
el('combo').textContent = combo;
}
function start(){
running = true;
live = 0; combo = 0; maxCombo = 0; hits = 0; bombs = 0; left = PLAY_SEC;
holes.forEach(clearHole);
el('live').textContent = 0;
el('combo').textContent = 0;
el('time').textContent = PLAY_SEC;
el('time').classList.remove('low');
el('score').textContent = '—';
el('detail').innerHTML = '';
el('start').style.display = 'none';
pop();
tickTimer = setInterval(function(){
left--;
el('time').textContent = left;
if (left <= 5) el('time').classList.add('low');
if (left <= 0) finish();
}, 1000);
}
function finish(){
running = false;
clearInterval(tickTimer);
clearTimeout(popTimer);
holes.forEach(clearHole);
el('detail').innerHTML =
'<div class="row"><span>잡은 두더지</span><b>' + hits + '마리</b></div>' +
'<div class="row"><span>밟은 폭탄</span><b>' + bombs + '개</b></div>' +
'<div class="row"><span>최고 연속</span><b>' + maxCombo + '연타</b></div>';
el('start').textContent = '다시 하기';
el('start').style.display = 'block';
gameOver(live);
}
function gameOver(score){
el('score').textContent = score;
el('best').textContent = saveScore(score);
}
el('start').addEventListener('click', start);
el('best').textContent = loadBest();
</script>
</body>
</html>
게임이 너무 커지는 게 가장 흔한 실패입니다. 처음부터 여러 단계나 여러 캐릭터를 넣지 마세요. 한 판 3분, 점수 하나. 이 두 가지만 지키면 3차시부터 모든 게 순조롭습니다.
안 움직일 때
"버튼을 눌러도 아무 일도 일어나지 않아. 왜 그런지 찾아서 고쳐 줘."
"화면이 하얗게만 나와. 고쳐 줘."
재미가 부족할 때
"연속으로 성공하면 점수가 더 많이 오르게 해 줘."
"시간이 지날수록 조금씩 빨라지게 해 줘."
"가끔 방해 요소가 나오게 해 줘. 그걸 누르면 점수가 깎여."
오늘 대화는 닫지 마세요. 다음 시간에 같은 대화를 이어서 열면 만들던 게임을 그대로 고칠 수 있습니다.
game.html으로 바꿔 내 저장소에 업로드 → Commit → 내주소/game.html으로 열어 보기올리는 법3차시 · 데이터 저장하여 기록 관리하기
새로고침해도 최고 점수는 남는다
지금 만든 게임에 최고 점수 저장 기능을 추가해 줘.
[추가할 것]
- saveScore(점수) 함수를 만들어 줘. localStorage에 'best_' + GAME_ID 라는 이름으로 최고 점수만 저장해.
- 지금까지의 최고 점수보다 높을 때만 바꿔 써.
- loadBest() 함수로 저장된 최고 점수를 읽어 와.
- 페이지를 열 때 최고 점수를 화면에 보여 줘.
- gameOver(점수) 안에서 saveScore를 부르도록 연결해 줘.
[화면]
- 현재 점수 아래에 "최고 점수"를 한 줄 더 보여 줘.
- 화면 맨 아래에 "기록은 이 브라우저에만 저장됩니다"라고 안내 문구를 작게 넣어 줘.
[주의]
- 저장 부분은 다른 코드와 섞지 말고 한 곳에 모아 줘. 주석으로 어디부터 어디까지인지 표시해 줘./* ===== 점수 저장 모듈 (3차시) =====
localStorage = 이 브라우저에만 남는 저장소 */
const GAME_ID = 'my-game'; // 내 게임마다 다른 이름으로
function saveScore(score){
const key = 'best_' + GAME_ID;
const best = Number(localStorage.getItem(key) || 0);
if (score > best) localStorage.setItem(key, String(score));
return Math.max(score, best);
}
function loadBest(){
return Number(localStorage.getItem('best_' + GAME_ID) || 0);
}
/* 게임이 끝날 때 이렇게 부릅니다 */
function gameOver(score){
document.getElementById('score').textContent = score;
document.getElementById('best').textContent = saveScore(score);
}캔버스에서 내 파일로 가져오기
저장 기능을 붙인 뒤에 받으세요. 먼저 받으면 저장 기능이 없는 파일이 됩니다.
.html (예: mygame.html)
순서가 중요합니다. 저장 기능을 붙인 뒤에 코드를 받으세요. 먼저 받으면 저장 기능이 없는 파일이 됩니다.
코드 받는 법
캔버스 오른쪽 위 메뉴에서 내려받거나, 코드 탭에서 전체를 복사해 메모장에 붙여 mygame.html로 저장합니다. 메모장을 쓸 때는 파일 형식을 모든 파일로 바꿔야 합니다.
저장이 됐는지 확인
받은 파일을 더블클릭해 열고 점수를 낸 뒤 F5를 눌러 보세요. 최고 점수가 그대로면 성공입니다.
여기서 반드시 확인할 것
같은 파일을 옆자리 컴퓨터에서 열어 보게 하세요. 내 기록이 없습니다. 이 경험이 5차시 로그인의 이유가 됩니다.
4차시 · 사용자 중심 학습 도구 개발하기
오늘부터 내가 진짜로 쓸 도구
"오늘 할 일" 도구를 만들어 줘. 내가 매일 실제로 쓸 거야.
[목표와 환경]
- HTML, CSS, 자바스크립트를 파일 하나에. 외부 라이브러리는 쓰지 마.
[디자인]
- 맨 위에 오늘 날짜를 한국어로 (예: 2026년 10월 12일 월요일).
- 그 아래에 "3 / 7" 같은 완료 개수와 막대 그래프.
- 입력칸과 추가 버튼은 한 줄로 나란히.
- 할 일은 카드 모양으로 세로로 쌓이게.
- 휴대폰에서도 가로 스크롤 없이.
[동작]
- 할 일을 적고 추가를 누르면 목록에 들어가.
- 왼쪽 네모를 누르면 완료 표시가 되고 글자에 줄이 그어져. 다시 누르면 풀려.
- 오른쪽 X를 누르면 그 항목만 지워져.
- "완료한 것 치우기" 버튼을 누르면 끝낸 것만 한 번에 사라져.
- 목록이 비어 있을 때는 안내 문구를 보여 줘.
[데이터 보관]
- localStorage에 저장해서 새로고침해도 남아 있게 해 줘.
- 화면 아래에 "이 목록은 지금 쓰는 브라우저에만 저장됩니다"라고 안내해 줘.<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#5B3DF5">
<title>오늘 할 일 · 내 컴퓨터에만 저장</title>
<style>
:root{--accent:#5B3DF5;--accent-dark:#4A2FD6;--ink:#171A2E;--muted:#62648A;
--line:#DCDCE8;--bg:#F3F3F8;--done:#9A9CBC}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:-apple-system,"Malgun Gothic","Apple SD Gothic Neo",sans-serif;
display:flex;flex-direction:column;align-items:center;padding:20px 14px 70px}
.app{width:100%;max-width:520px}
header{margin-bottom:18px}
h1{font-size:27px;margin:0 0 2px}
.date{color:var(--muted);font-size:15px;margin:0}
.progress{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin-bottom:14px}
.ptop{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px}
.ptop b{font-size:25px}
.ptop span{color:var(--muted);font-size:14px}
.bar{height:12px;border-radius:6px;background:#E4E4EF;overflow:hidden}
.bar i{display:block;height:100%;background:var(--accent);width:0;transition:width .25s ease}
form{display:flex;gap:8px;margin-bottom:14px}
input[type=text]{flex:1;padding:13px 14px;font-size:16px;border:1px solid var(--line);
border-radius:10px;font-family:inherit;background:#fff}
input[type=text]:focus{outline:2px solid var(--accent);outline-offset:-1px}
button{background:var(--accent);color:#fff;border:0;border-radius:10px;padding:0 20px;
font-size:16px;cursor:pointer;font-family:inherit}
button:hover{background:var(--accent-dark)}
ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
li{display:flex;align-items:center;gap:12px;background:#fff;border:1px solid var(--line);
border-radius:11px;padding:13px 14px}
li .chk{width:24px;height:24px;flex:none;border:2px solid var(--line);border-radius:7px;
background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;
font-size:15px;color:#fff;padding:0}
li.done .chk{background:var(--accent);border-color:var(--accent)}
li .txt{flex:1;font-size:16px;word-break:break-all;line-height:1.5}
li.done .txt{color:var(--done);text-decoration:line-through}
li .del{background:none;color:var(--muted);font-size:19px;padding:0 4px;line-height:1}
li .del:hover{background:none;color:#D9435A}
.empty{background:#fff;border:1px dashed var(--line);border-radius:12px;padding:34px 18px;
text-align:center;color:var(--muted);line-height:1.7}
.tools{display:flex;justify-content:space-between;align-items:center;margin-top:16px;
color:var(--muted);font-size:14px}
.tools button{background:none;color:var(--muted);font-size:14px;padding:6px 10px;
border:1px solid var(--line);border-radius:8px}
.tools button:hover{background:#F0EFF9;color:var(--ink)}
.note{background:#fff8e8;border:1px solid #f0dcb0;border-radius:11px;padding:13px 15px;
margin-top:22px;font-size:14px;line-height:1.7;color:#6b4e12}
:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
nav :focus-visible{outline-color:#FFD400}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div class="app">
<header>
<h1>오늘 할 일</h1>
<p class="date" id="date"></p>
</header>
<div class="progress">
<div class="ptop"><b id="ratio">0 / 0</b><span id="cheer">할 일을 적어 보세요</span></div>
<div class="bar"><i id="fill"></i></div>
</div>
<form id="f">
<input type="text" id="input" placeholder="무엇을 할까요?" maxlength="60" autocomplete="off">
<button type="submit">추가</button>
</form>
<ul id="list"></ul>
<div class="empty" id="empty">아직 비어 있습니다.<br>위에 할 일을 적고 추가를 눌러 보세요.</div>
<div class="tools">
<span id="count"></span>
<button id="clear">완료한 것 치우기</button>
</div>
<div class="note">
<b>이 목록은 지금 쓰는 브라우저에만 저장됩니다.</b><br>
다른 컴퓨터나 휴대폰에서 열면 비어 있습니다. 어디서든 보이게 하려면 로그인과 클라우드 저장이 필요합니다 — 5차시에 만듭니다.
</div>
</div>
<script>
/* ===== 저장: localStorage = 이 브라우저에만 남는 저장소 ===== */
const KEY = 'todo_local_v1';
let items = [];
function load(){
try { items = JSON.parse(localStorage.getItem(KEY) || '[]'); }
catch (e) { items = []; }
if (!Array.isArray(items)) items = [];
}
function save(){
localStorage.setItem(KEY, JSON.stringify(items));
}
/* ===== 화면 ===== */
const el = function(id){ return document.getElementById(id); };
function today(){
const d = new Date();
const days = ['일','월','화','수','목','금','토'];
return d.getFullYear() + '년 ' + (d.getMonth()+1) + '월 ' + d.getDate() + '일 ' + days[d.getDay()] + '요일';
}
function render(){
const list = el('list');
list.innerHTML = '';
items.forEach(function(it){
const li = document.createElement('li');
if (it.done) li.className = 'done';
const chk = document.createElement('button');
chk.className = 'chk';
chk.textContent = it.done ? '✓' : '';
chk.setAttribute('aria-label', it.done ? '완료 취소' : '완료로 표시');
chk.addEventListener('click', function(){
it.done = !it.done; save(); render();
});
const txt = document.createElement('span');
txt.className = 'txt';
txt.textContent = it.text;
const del = document.createElement('button');
del.className = 'del';
del.textContent = '×';
del.setAttribute('aria-label', '삭제');
del.addEventListener('click', function(){
items = items.filter(function(x){ return x.id !== it.id; });
save(); render();
});
li.append(chk, txt, del);
list.appendChild(li);
});
const total = items.length;
const done = items.filter(function(x){ return x.done; }).length;
el('empty').style.display = total ? 'none' : 'block';
el('ratio').textContent = done + ' / ' + total;
el('fill').style.width = total ? (done / total * 100) + '%' : '0%';
el('count').textContent = total ? ('남은 일 ' + (total - done) + '개') : '';
el('cheer').textContent =
total === 0 ? '할 일을 적어 보세요' :
done === total ? '오늘 할 일을 다 했어요' :
done === 0 ? '하나만 먼저 해 볼까요' : '잘하고 있어요';
}
el('f').addEventListener('submit', function(e){
e.preventDefault();
const v = el('input').value.trim();
if (!v) return;
items.push({ id: Date.now() + '_' + Math.random().toString(36).slice(2,7), text: v, done: false });
el('input').value = '';
save(); render();
});
el('clear').addEventListener('click', function(){
items = items.filter(function(x){ return !x.done; });
save(); render();
});
el('date').textContent = today();
load();
render();
</script>
</body>
</html>
4차시 결과물은 오늘부터 실제로 쓰게 하세요. 일주일 써 본 학생과 안 써 본 학생은 5차시 몰입도가 완전히 다릅니다.
오늘은 올리기까지 해야 합니다. 내 저장소에 올라가 있어야 다음 시간에 로그인을 붙여 확인할 수 있습니다. 파일 이름은 todo.html로 통일하세요.
다른 도구로 바꾸고 싶다면
"과목별 시간표를 만들어 줘. 칸을 누르면 과목 이름을 고칠 수 있게."
"25분 공부하고 5분 쉬는 타이머를 만들어 줘. 몇 번 했는지 세어 줘."
"시험까지 며칠 남았는지 세어 주는 화면을 만들어 줘."
더 해 볼 요청
"할 일에 중요 표시를 달 수 있게 해 줘. 중요한 건 위로 올라오게."
"어제 못 끝낸 일이 오늘 목록에 남아 있게 해 줘.
todo.html으로 바꿔 내 저장소에 업로드 → Commit → 내주소/todo.html으로 열어 보기올리는 법5차시 · 계정 인증과 클라우드 데이터베이스 연동하기
로그인하면 집에서도 내 것이 그대로
지금 만든 할 일 도구에 구글 로그인과 클라우드 저장을 붙여 줘.
[설정값]
아래 firebaseConfig를 코드 맨 위에 넣고 써 줘.
(여기에 내 파이어베이스 프로젝트의 설정값을 붙여넣으세요)
[방식]
- 파이어베이스 v10 모듈 방식으로, https://www.gstatic.com/firebasejs/10.12.2/ 에서 불러와 줘.
- <script type="module"> 안에 넣어 줘.
[로그인]
- 로그인 전에는 "구글 계정으로 로그인" 버튼만 보이는 안내 화면을 보여 줘.
- 로그인하면 내 이름과 프로필 사진, 로그아웃 버튼을 위쪽에 보여 줘.
[저장]
- 할 일 목록을 users/{내 uid}/data/todos 문서에 { items: [...] } 모양으로 저장해 줘.
- 로그인하면 저장된 목록을 불러와서 화면에 보여 줘.
- 목록이 바뀌면 자동으로 저장해 줘. 다만 글자를 칠 때마다 저장하지 말고 0.6초쯤 기다렸다 한 번만 저장해.
- "저장 중…" / "저장됨" 같은 상태를 작게 보여 줘.
[안 될 때]
- 설정값이 없거나 인터넷 연결이 안 되면, 오류로 멈추지 말고 localStorage에만 저장하는 연습 모드로 돌아가게 해 줘. 그 상태를 화면에 안내해 줘.
- 로그인이 실패하면 오류 코드를 화면에 그대로 보여 줘. 무엇 때문인지 알아야 고칠 수 있어.
[안내 문구]
- 화면 아래에 "내 할 일은 내 계정에만 저장되고 다른 사람은 볼 수 없습니다"라고 넣어 줘.// ===== 내 파이어베이스 설정값 =====
// 파이어베이스 콘솔 → 프로젝트 설정(⚙️) → 내 앱 → 구성 에서 복사해
// 아래 전체를 바꿔 넣으세요. apiKey 는 비밀번호가 아니라 주소표입니다.
const firebaseConfig = {
apiKey: "여기에-내-설정값을-붙여넣으세요",
authDomain: "내프로젝트.firebaseapp.com",
projectId: "내프로젝트",
appId: "내앱아이디"
};내 설정값은 어디서 찾나
파이어베이스 콘솔 → 왼쪽 위 프로젝트 설정(⚙️) → 아래로 내려 내 앱 → 구성(Config). const firebaseConfig = { … } 전체를 복사합니다.

마지막에 보안 규칙을 게시하세요
테스트 모드로 만든 데이터베이스는 30일 동안 누구나 읽고 쓸 수 있습니다. 아래를 복사해 Firestore → 규칙 탭에 통째로 붙여넣고 게시를 누르세요.
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 내 자료는 나만 읽고 쓴다
match /users/{uid}/{document=**} {
allow read, write: if request.auth != null && request.auth.uid == uid;
}
// 그 외의 모든 곳은 막는다
match /{document=**} {
allow read, write: if false;
}
}
}<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#5B3DF5">
<title>내 할 일 · 어디서나 열리는 목록</title>
<style>
:root{--accent:#5B3DF5;--accent-dark:#4A2FD6;--ink:#171A2E;--muted:#62648A;
--line:#DCDCE8;--bg:#F3F3F8;--done:#9A9CBC;--warn:#8a5a00}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:-apple-system,"Malgun Gothic","Apple SD Gothic Neo",sans-serif;
display:flex;flex-direction:column;align-items:center;padding:20px 14px 70px}
.app{width:100%;max-width:520px}
.bar-top{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:16px}
h1{font-size:26px;margin:0}
.who{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--muted)}
.who img{width:30px;height:30px;border-radius:50%;background:#E4E4EF}
button{background:var(--accent);color:#fff;border:0;border-radius:10px;padding:10px 18px;
font-size:15px;cursor:pointer;font-family:inherit}
button:hover{background:var(--accent-dark)}
button.ghost{background:none;color:var(--muted);border:1px solid var(--line);padding:7px 12px;font-size:14px}
button.ghost:hover{background:#F0EFF9;color:var(--ink)}
.gate{background:#fff;border:1px solid var(--line);border-radius:10px;padding:34px 24px;text-align:center}
.gate h2{font-size:21px;margin:0 0 10px}
.gate p{color:var(--muted);line-height:1.75;margin:0 0 20px;font-size:15px}
.gate button{font-size:16px;padding:13px 26px}
.status{background:#fff;border:1px solid var(--line);border-radius:12px;padding:11px 14px;
font-size:14px;color:var(--muted);margin-bottom:12px;display:flex;
justify-content:space-between;align-items:center;gap:10px}
.status b{color:var(--accent-dark)}
.banner{background:#fff4e6;border:1px solid #f0c391;border-left:5px solid var(--warn);
border-radius:11px;padding:13px 15px;font-size:14.5px;line-height:1.7;color:#6b4412;margin-bottom:14px}
.banner b{color:var(--warn)}
.progress{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin-bottom:14px}
.ptop{display:flex;justify-content:space-between;align-items:baseline;margin-bottom:10px}
.ptop b{font-size:24px}
.ptop span{color:var(--muted);font-size:14px}
.bar{height:12px;border-radius:6px;background:#E4E4EF;overflow:hidden}
.bar i{display:block;height:100%;background:var(--accent);width:0;transition:width .25s ease}
form{display:flex;gap:8px;margin-bottom:14px}
input[type=text]{flex:1;padding:13px 14px;font-size:16px;border:1px solid var(--line);
border-radius:10px;font-family:inherit;background:#fff}
input[type=text]:focus{outline:2px solid var(--accent);outline-offset:-1px}
ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:8px}
li{display:flex;align-items:center;gap:12px;background:#fff;border:1px solid var(--line);
border-radius:11px;padding:13px 14px}
li .chk{width:24px;height:24px;flex:none;border:2px solid var(--line);border-radius:7px;
background:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;
font-size:15px;color:#fff;padding:0}
li.done .chk{background:var(--accent);border-color:var(--accent)}
li .txt{flex:1;font-size:16px;word-break:break-all;line-height:1.5}
li.done .txt{color:var(--done);text-decoration:line-through}
li .del{background:none;color:var(--muted);font-size:19px;padding:0 4px;line-height:1}
li .del:hover{background:none;color:#D9435A}
.empty{background:#fff;border:1px dashed var(--line);border-radius:12px;padding:32px 18px;
text-align:center;color:var(--muted);line-height:1.7}
.tools{display:flex;justify-content:space-between;align-items:center;margin-top:16px;
color:var(--muted);font-size:14px}
.privacy{margin-top:24px;font-size:13.5px;color:var(--muted);line-height:1.8;
border-top:1px solid var(--line);padding-top:14px}
.hide{display:none}
:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
nav :focus-visible{outline-color:#FFD400}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div class="app">
<div class="bar-top">
<h1>내 할 일</h1>
<div class="who hide" id="who">
<img id="photo" alt="">
<span id="name"></span>
<button class="ghost" id="out">로그아웃</button>
</div>
</div>
<div class="banner hide" id="setup">
<b>아직 설정값을 넣지 않았습니다.</b><br>
아래 <code>firebaseConfig</code> 자리에 <b>내 프로젝트</b> 설정값을 붙여넣으면 로그인과 클라우드 저장이 켜집니다.
지금은 이 브라우저에만 저장되는 연습 모드로 동작합니다.
</div>
<div class="gate hide" id="gate">
<h2>로그인하면 어디서나 같은 목록</h2>
<p>학교 컴퓨터에서 적은 할 일이 집에서도, 휴대폰에서도 그대로 보입니다.<br>
내 목록은 나만 볼 수 있습니다.</p>
<button id="in">구글 계정으로 로그인</button>
</div>
<div class="hide" id="main">
<div class="status">
<span id="sync">불러오는 중…</span>
<b id="mode"></b>
</div>
<div class="progress">
<div class="ptop"><b id="ratio">0 / 0</b><span id="cheer">할 일을 적어 보세요</span></div>
<div class="bar"><i id="fill"></i></div>
</div>
<form id="f">
<input type="text" id="input" placeholder="무엇을 할까요?" maxlength="60" autocomplete="off">
<button type="submit">추가</button>
</form>
<ul id="list"></ul>
<div class="empty" id="empty">아직 비어 있습니다.<br>위에 할 일을 적고 추가를 눌러 보세요.</div>
<div class="tools">
<span id="count"></span>
<button class="ghost" id="clear">완료한 것 치우기</button>
</div>
<div class="privacy">
내 할 일은 내 계정에만 저장되고, 다른 사람은 볼 수 없습니다. 보안 규칙이 그렇게 막아 두었습니다.<br>
저장되는 것은 구글 계정 번호와 내가 적은 할 일뿐입니다. 이름·연락처·주소처럼 남에게 알려지면 곤란한 내용은 적지 마세요.
</div>
</div>
</div>
<script type="module">
/* =========================================================
우리 반 파이어베이스 설정값
강사가 준 블록으로 아래를 통째로 바꾸세요.
========================================================= */
const firebaseConfig = {
apiKey: "여기에-내-설정값을-붙여넣으세요",
authDomain: "내프로젝트.firebaseapp.com",
projectId: "내프로젝트",
appId: "내앱아이디"
};
/* ========================================================= */
const el = (id) => document.getElementById(id);
const READY = !String(firebaseConfig.apiKey).startsWith('여기에');
let items = [];
let uid = null;
let saveTimer = null;
let cloud = null; // { db, doc, setDoc, getDoc }
/* ---------- 저장: 클라우드가 있으면 클라우드, 없으면 이 브라우저 ---------- */
const LOCAL_KEY = 'todo_cloud_fallback_v1';
function localLoad(){
try { const v = JSON.parse(localStorage.getItem(LOCAL_KEY) || '[]'); return Array.isArray(v) ? v : []; }
catch (e) { return []; }
}
function localSave(){ localStorage.setItem(LOCAL_KEY, JSON.stringify(items)); }
function scheduleSave(){
el('sync').textContent = '저장 중…';
clearTimeout(saveTimer);
saveTimer = setTimeout(doSave, 600);
}
async function doSave(){
if (cloud && uid){
try {
await cloud.setDoc(cloud.doc(cloud.db, 'users', uid, 'data', 'todos'), { items });
el('sync').textContent = '저장됨';
} catch (e){
el('sync').textContent = '저장 실패 — 인터넷을 확인하세요';
console.error(e);
}
} else {
localSave();
el('sync').textContent = '저장됨';
}
}
/* ---------- 화면 ---------- */
function render(){
const list = el('list');
list.innerHTML = '';
items.forEach((it) => {
const li = document.createElement('li');
if (it.done) li.className = 'done';
const chk = document.createElement('button');
chk.className = 'chk';
chk.textContent = it.done ? '✓' : '';
chk.setAttribute('aria-label', it.done ? '완료 취소' : '완료로 표시');
chk.addEventListener('click', () => { it.done = !it.done; render(); scheduleSave(); });
const txt = document.createElement('span');
txt.className = 'txt';
txt.textContent = it.text;
const del = document.createElement('button');
del.className = 'del';
del.textContent = '×';
del.setAttribute('aria-label', '삭제');
del.addEventListener('click', () => {
items = items.filter((x) => x.id !== it.id);
render(); scheduleSave();
});
li.append(chk, txt, del);
list.appendChild(li);
});
const total = items.length;
const done = items.filter((x) => x.done).length;
el('empty').style.display = total ? 'none' : 'block';
el('ratio').textContent = done + ' / ' + total;
el('fill').style.width = total ? (done / total * 100) + '%' : '0%';
el('count').textContent = total ? ('남은 일 ' + (total - done) + '개') : '';
el('cheer').textContent =
total === 0 ? '할 일을 적어 보세요' :
done === total ? '오늘 할 일을 다 했어요' :
done === 0 ? '하나만 먼저 해 볼까요' : '잘하고 있어요';
}
el('f').addEventListener('submit', (e) => {
e.preventDefault();
const v = el('input').value.trim();
if (!v) return;
items.push({ id: Date.now() + '_' + Math.random().toString(36).slice(2,7), text: v, done: false });
el('input').value = '';
render(); scheduleSave();
});
el('clear').addEventListener('click', () => {
items = items.filter((x) => !x.done);
render(); scheduleSave();
});
/* ---------- 연습 모드 (설정값 없음) ---------- */
function startPracticeMode(reason){
el('setup').classList.remove('hide');
if (reason) el('setup').innerHTML = '<b>' + reason + '</b><br>지금은 이 브라우저에만 저장되는 연습 모드로 동작합니다.';
el('main').classList.remove('hide');
el('mode').textContent = '연습 모드';
items = localLoad();
render();
el('sync').textContent = '이 브라우저에만 저장됩니다';
}
/* ---------- 클라우드 모드 ---------- */
async function startCloudMode(){
const [{ initializeApp }, authMod, fsMod] = await Promise.all([
import('https://www.gstatic.com/firebasejs/10.12.2/firebase-app.js'),
import('https://www.gstatic.com/firebasejs/10.12.2/firebase-auth.js'),
import('https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js')
]);
const app = initializeApp(firebaseConfig);
const auth = authMod.getAuth(app);
const db = fsMod.getFirestore(app);
cloud = { db, doc: fsMod.doc, setDoc: fsMod.setDoc, getDoc: fsMod.getDoc };
el('in').addEventListener('click', () => {
authMod.signInWithPopup(auth, new authMod.GoogleAuthProvider()).catch((e) => {
alert('로그인이 되지 않았습니다.\n\n' + e.code + '\n\n선생님께 이 메시지를 보여 주세요.');
});
});
el('out').addEventListener('click', () => authMod.signOut(auth));
authMod.onAuthStateChanged(auth, async (user) => {
if (!user){
uid = null; items = [];
el('gate').classList.remove('hide');
el('main').classList.add('hide');
el('who').classList.add('hide');
return;
}
uid = user.uid;
el('gate').classList.add('hide');
el('main').classList.remove('hide');
el('who').classList.remove('hide');
el('name').textContent = user.displayName || '나';
if (user.photoURL) el('photo').src = user.photoURL;
el('mode').textContent = '클라우드 저장';
el('sync').textContent = '불러오는 중…';
try {
const snap = await cloud.getDoc(cloud.doc(db, 'users', uid, 'data', 'todos'));
const data = snap.exists() ? snap.data() : null;
items = (data && Array.isArray(data.items)) ? data.items : [];
el('sync').textContent = '어디서나 같은 목록이 보입니다';
} catch (e){
items = [];
el('sync').textContent = '불러오지 못했습니다 — 인터넷을 확인하세요';
console.error(e);
}
render();
});
}
/* ---------- 시작 ---------- */
if (!READY){
startPracticeMode();
} else {
startCloudMode().catch((e) => {
console.error(e);
startPracticeMode('파이어베이스에 연결하지 못했습니다.');
});
}
</script>
</body>
</html>
승인된 도메인을 빠뜨리는 사람이 가장 많습니다. Authentication → Settings → 승인된 도메인에 내아이디.github.io를 넣어야 로그인 창이 열립니다. https://를 빼고, 뒤의 저장소 이름도 빼고 도메인만 넣으세요.
캔버스 미리보기에서는 로그인 창이 안 뜰 수 있습니다. 확인은 반드시 올린 뒤 내 주소에서 하세요.
오늘 흐름
① 내 프로젝트 만들기 → ② 로그인·데이터베이스 켜기 → ③ 설정값 복사 → ④ 도메인 등록 → ⑤ 캔버스에서 붙이기 → ⑥ 올리고 내 주소에서 확인 → ⑦ 보안 규칙 게시
안 될 때 화면에 뜨는 글자를 그대로 보세요
auth/unauthorized-domain — 승인된 도메인에 내 주소를 안 넣었습니다.
auth/popup-blocked — 브라우저가 팝업을 막았습니다. 주소창 오른쪽의 팝업 허용을 누르세요.
permission-denied — 보안 규칙이 막은 것입니다. 저장하려는 위치가 users/내uid/ 안인지 확인하세요.
api-key-not-valid — 설정값을 잘못 붙여넣었습니다. 콘솔에서 다시 복사하세요.
마지막에 보안 규칙을 꼭 게시하세요. 테스트 모드로 만든 데이터베이스는 30일 동안 누구나 읽고 쓸 수 있습니다. 아래 규칙을 복사해 Firestore → 규칙 탭에 붙여넣고 게시를 누르세요.
todo.html으로 바꿔 내 저장소에 업로드 → Commit → 내주소/todo.html으로 열어 보기올리는 법6차시 · 웹 서비스 배포하고 공유하기
주소가 생기면 친구가 내 게임을 한다
지금 만든 게임에 구글 로그인과 클라우드 최고 기록을 붙여 줘. (해 보고 싶은 사람만)
[설정값]
아래 firebaseConfig를 코드 맨 위에 넣어 줘.
(여기에 내 파이어베이스 프로젝트의 설정값을 붙여넣으세요)
[방식]
- 파이어베이스 v10 모듈 방식으로, https://www.gstatic.com/firebasejs/10.12.2/ 에서 불러와 줘.
[추가할 것]
- 화면 위쪽에 작은 "로그인" 버튼을 넣어 줘. 로그인하지 않아도 게임은 그냥 되게 해 줘.
- 로그인한 상태에서 게임이 끝나면, 지금까지의 최고 점수를
users/{내 uid}/data/highscore 문서에 { score: 숫자 } 로 저장해 줘.
- 지금 점수가 저장된 값보다 높을 때만 바꿔 써 줘.
- 페이지를 열 때 저장된 최고 점수를 불러와 화면에 보여 줘.
- 로그인하지 않았으면 3차시에 만든 localStorage 기록을 그대로 쓰게 해 줘.전시관에 등록하기
전시관은 선생님이 만든 곳입니다. 설정값을 넣을 일은 없고 구글 로그인만 하면 됩니다.
등록할 주소는 내아이디.github.io/my-web/game.html 모양입니다. 올리기 전에 직접 눌러서 열리는지 확인하세요. 안 열리는 주소를 올리면 친구가 못 합니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#5B3DF5">
<title>우리 반 전시관</title>
<style>
:root{--accent:#5B3DF5;--accent-dark:#4A2FD6;--accent-soft:#EDE9FE;--ink:#171A2E;
--muted:#62648A;--line:#DCDCE8;--bg:#F3F3F8;--warn:#8a5a00;--gold:#B07A00}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);
font-family:-apple-system,"Malgun Gothic","Apple SD Gothic Neo",sans-serif;
display:flex;flex-direction:column;align-items:center;padding:20px 14px 70px}
.app{width:100%;max-width:720px}
.bar-top{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:6px;flex-wrap:wrap}
h1{font-size:28px;margin:0}
.lead{color:var(--muted);margin:0 0 18px;font-size:15px}
.who{display:flex;align-items:center;gap:8px;font-size:14px;color:var(--muted)}
.who img{width:30px;height:30px;border-radius:50%;background:#E4E4EF}
button{background:var(--accent);color:#fff;border:0;border-radius:10px;padding:10px 18px;
font-size:15px;cursor:pointer;font-family:inherit}
button:hover{background:var(--accent-dark)}
button.ghost{background:none;color:var(--muted);border:1px solid var(--line);padding:7px 12px;font-size:14px}
button.ghost:hover{background:#F0EFF9;color:var(--ink)}
.banner{background:#fff4e6;border:1px solid #f0c391;border-left:5px solid var(--warn);
border-radius:11px;padding:13px 15px;font-size:14.5px;line-height:1.7;color:#6b4412;margin-bottom:16px}
.banner b{color:var(--warn)}
.gate{background:#fff;border:1px solid var(--line);border-radius:10px;padding:34px 24px;text-align:center}
.gate h2{font-size:21px;margin:0 0 10px}
.gate p{color:var(--muted);line-height:1.75;margin:0 0 20px;font-size:15px}
.tabs{display:flex;gap:6px;margin:18px 0 14px;border-bottom:2px solid var(--line)}
.tabs button{background:none;color:var(--muted);border:0;border-bottom:3px solid transparent;
border-radius:0;padding:10px 14px;font-size:16px;margin-bottom:-2px}
.tabs button.on{color:var(--accent-dark);border-bottom-color:var(--accent);font-weight:700}
.tabs button:hover{background:none;color:var(--ink)}
.cards{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}
.card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px;
display:flex;flex-direction:column;gap:7px}
.card h3{margin:0;font-size:17px;line-height:1.4;word-break:break-all}
.card .by{color:var(--muted);font-size:13.5px}
.card p{margin:0;font-size:14.5px;color:#3A3C5C;line-height:1.6;word-break:break-all}
.card a{margin-top:auto;display:inline-block;background:var(--accent);color:#fff;
text-decoration:none;text-align:center;border-radius:9px;padding:10px;font-size:15px}
.card a:hover{background:var(--accent-dark)}
.rank{background:#fff;border:1px solid var(--line);border-radius:10px;padding:16px 18px;margin-bottom:12px}
.rank h3{margin:0 0 10px;font-size:17px;display:flex;justify-content:space-between;align-items:baseline}
.rank h3 span{font-size:13px;color:var(--muted);font-weight:400}
.rk{display:flex;align-items:center;gap:12px;padding:8px 0;border-bottom:1px dashed var(--line);font-size:15px}
.rk:last-child{border:0}
.rk .n{width:26px;text-align:center;color:var(--muted);font-size:14px}
.rk.top .n{color:var(--gold);font-weight:700}
.rk .who2{flex:1;word-break:break-all}
.rk .sc{font-weight:700;font-size:17px}
.rk.me{background:var(--accent-soft);border-radius:8px;padding:8px 10px;margin:0 -10px}
.panel{background:#fff;border:1px solid var(--line);border-radius:10px;padding:18px;margin-bottom:14px}
.panel h3{margin:0 0 4px;font-size:17px}
.panel .hint{color:var(--muted);font-size:14px;margin:0 0 12px;line-height:1.6}
.field{display:flex;flex-direction:column;gap:5px;margin-bottom:10px}
.field label{font-size:14px;color:var(--muted)}
input[type=text],input[type=url]{padding:12px;font-size:15.5px;border:1px solid var(--line);
border-radius:10px;font-family:inherit;background:#fff;width:100%}
input:focus{outline:2px solid var(--accent);outline-offset:-1px}
.empty{background:#fff;border:1px dashed var(--line);border-radius:12px;padding:34px 18px;
text-align:center;color:var(--muted);line-height:1.7}
.msg{font-size:14px;margin-top:8px;min-height:20px}
.msg.ok{color:var(--accent-dark)} .msg.no{color:#D9435A}
.safe{margin-top:26px;font-size:13.5px;color:var(--muted);line-height:1.8;
border-top:1px solid var(--line);padding-top:14px}
.hide{display:none}
:focus-visible{outline:3px solid var(--accent);outline-offset:2px}
nav :focus-visible{outline-color:#FFD400}
@media (prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div class="app">
<div class="bar-top">
<h1>우리 반 전시관</h1>
<div class="who hide" id="who">
<img id="photo" alt=""><span id="name"></span>
<button class="ghost" id="out">로그아웃</button>
</div>
</div>
<p class="lead">친구들이 만든 게임을 눌러서 해 보고, 점수를 남겨 보세요.</p>
<div class="banner hide" id="setup">
<b>아직 설정값을 넣지 않았습니다.</b><br>
<code>firebaseConfig</code> 자리에 전시관용 설정값을 붙여넣으면 전시관이 열립니다. 학생은 로그인만 하면 됩니다.
</div>
<div class="gate hide" id="gate">
<h2>로그인하고 내 작품 올리기</h2>
<p>로그인하면 내가 만든 게임을 전시하고, 점수를 랭킹에 남길 수 있습니다.</p>
<button id="in">구글 계정으로 로그인</button>
</div>
<div class="hide" id="main">
<div class="tabs">
<button class="on" data-tab="gal">🎮 작품</button>
<button data-tab="rank">🏆 랭킹</button>
<button data-tab="mine">✍️ 내 작품 올리기</button>
</div>
<div id="tab-gal">
<div class="cards" id="cards"></div>
<div class="empty hide" id="galEmpty">아직 올라온 작품이 없습니다.<br>'내 작품 올리기'에서 첫 번째가 되어 보세요.</div>
</div>
<div id="tab-rank" class="hide">
<div id="ranks"></div>
<div class="empty hide" id="rankEmpty">아직 기록이 없습니다.<br>친구 게임을 해 보고 '내 작품 올리기'에서 점수를 등록하세요.</div>
</div>
<div id="tab-mine" class="hide">
<div class="panel">
<h3>내 별명</h3>
<p class="hint">랭킹에 이 이름으로 올라갑니다. 실명 대신 별명을 쓰세요.</p>
<div class="field">
<input type="text" id="nick" maxlength="12" placeholder="예: 초록두더지">
</div>
<button id="saveNick">별명 저장</button>
<div class="msg" id="nickMsg"></div>
</div>
<div class="panel">
<h3>내 게임 올리기</h3>
<p class="hint">배포된 주소를 넣으면 친구들이 눌러서 바로 할 수 있습니다. 이미 올렸다면 덮어씁니다.</p>
<div class="field"><label for="title">게임 이름</label>
<input type="text" id="title" maxlength="40" placeholder="예: 30초 두더지"></div>
<div class="field"><label for="desc">한 줄 설명</label>
<input type="text" id="desc" maxlength="60" placeholder="예: 폭탄을 피해서 두더지만 잡으세요"></div>
<div class="field"><label for="url">게임 주소</label>
<input type="url" id="url" placeholder="https://..."></div>
<button id="saveWork">전시관에 올리기</button>
<div class="msg" id="workMsg"></div>
</div>
<div class="panel">
<h3>점수 등록</h3>
<p class="hint">친구 게임을 해 보고 최고 점수를 올립니다. 게임마다 하나씩 올라갑니다.</p>
<div class="field"><label for="sgame">어느 게임</label>
<input type="text" id="sgame" list="gamelist" maxlength="30" placeholder="예: 30초 두더지">
<datalist id="gamelist"></datalist></div>
<div class="field"><label for="sscore">내 점수</label>
<input type="number" id="sscore" min="0" max="1000000" step="1" placeholder="예: 24"></div>
<button id="saveScore">점수 올리기</button>
<div class="msg" id="scoreMsg"></div>
</div>
</div>
<div class="safe">
전시관은 반 친구 누구나 볼 수 있습니다. 실명·사진·연락처·학교 이름은 넣지 마세요.<br>
올린 작품과 점수는 지울 수 없게 되어 있습니다. 고치고 싶으면 다시 올려 덮어쓰세요.
</div>
</div>
</div>
<script type="module">
/* ═══════════════════════════════════════════════════════
▼ 강사용 — 여기 한 곳만 바꾸세요 ▼
전시관용 파이어베이스 프로젝트의 설정값을 붙여넣습니다.
학생은 이 파일을 고칠 일이 없습니다. 로그인만 하면 됩니다.
═══════════════════════════════════════════════════════ */
const firebaseConfig = {
apiKey: "여기에-전시관-설정값을-붙여넣으세요",
authDomain: "프로젝트이름.firebaseapp.com",
projectId: "프로젝트이름",
appId: "앱아이디"
};
/* ═══════════════════ 여기까지 ═══════════════════ */
const el = (id) => document.getElementById(id);
const READY = !String(firebaseConfig.apiKey).startsWith('여기에');
let uid = null, fs = null, db = null;
/* 탭 */
document.querySelectorAll('.tabs button').forEach((b) => {
b.addEventListener('click', () => {
document.querySelectorAll('.tabs button').forEach((x) => x.classList.remove('on'));
b.classList.add('on');
['gal','rank','mine'].forEach((t) => {
el('tab-' + t).classList.toggle('hide', t !== b.dataset.tab);
});
});
});
function setMsg(id, text, ok){
const m = el(id);
m.textContent = text;
m.className = 'msg ' + (ok ? 'ok' : 'no');
}
if (!READY){
el('setup').classList.remove('hide');
} else {
start().catch((e) => {
console.error(e);
el('setup').classList.remove('hide');
el('setup').innerHTML = '<b>파이어베이스에 연결하지 못했습니다.</b><br>인터넷 연결과 설정값을 확인하세요.';
});
}
async function start(){
const [{ initializeApp }, authMod, fsMod] = await Promise.all([
import('https://www.gstatic.com/firebasejs/10.12.2/firebase-app.js'),
import('https://www.gstatic.com/firebasejs/10.12.2/firebase-auth.js'),
import('https://www.gstatic.com/firebasejs/10.12.2/firebase-firestore.js')
]);
const app = initializeApp(firebaseConfig);
const auth = authMod.getAuth(app);
db = fsMod.getFirestore(app);
fs = fsMod;
el('in').addEventListener('click', () => {
authMod.signInWithPopup(auth, new authMod.GoogleAuthProvider()).catch((e) => {
alert('로그인이 되지 않았습니다.\n\n' + e.code + '\n\n선생님께 이 메시지를 보여 주세요.');
});
});
el('out').addEventListener('click', () => authMod.signOut(auth));
authMod.onAuthStateChanged(auth, async (user) => {
if (!user){
uid = null;
el('gate').classList.remove('hide');
el('main').classList.add('hide');
el('who').classList.add('hide');
return;
}
uid = user.uid;
el('gate').classList.add('hide');
el('main').classList.remove('hide');
el('who').classList.remove('hide');
el('name').textContent = user.displayName || '나';
if (user.photoURL) el('photo').src = user.photoURL;
el('nick').value = localStorage.getItem('nickname') || '';
await Promise.all([loadGallery(), loadRanking()]);
});
el('saveNick').addEventListener('click', () => {
const v = el('nick').value.trim();
if (v.length < 1){ setMsg('nickMsg', '별명을 적어 주세요.', false); return; }
localStorage.setItem('nickname', v);
setMsg('nickMsg', '저장했습니다. 이제 게임 점수가 이 이름으로 올라갑니다.', true);
});
el('saveWork').addEventListener('click', async () => {
const title = el('title').value.trim();
const desc = el('desc').value.trim();
const url = el('url').value.trim();
const nick = localStorage.getItem('nickname') || '';
if (!title){ setMsg('workMsg', '게임 이름을 적어 주세요.', false); return; }
if (!nick){ setMsg('workMsg', '먼저 위에서 별명을 저장해 주세요.', false); return; }
if (url && !/^https?:\/\//i.test(url)){
setMsg('workMsg', '주소는 https:// 로 시작해야 합니다.', false); return;
}
try {
await fs.setDoc(fs.doc(db, 'gallery', uid), {
uid, title, desc, url, nickname: nick, at: Date.now()
});
setMsg('workMsg', '전시관에 올렸습니다.', true);
await loadGallery();
fillGameList();
} catch (e){
setMsg('workMsg', '올리지 못했습니다: ' + e.code, false);
console.error(e);
}
});
/* 점수 등록 — 게임은 자기 프로젝트에 자기 기록을 저장하므로,
반 전체 랭킹은 여기서 직접 올린다. */
el('saveScore').addEventListener('click', async () => {
const game = el('sgame').value.trim();
const raw = el('sscore').value.trim();
const nick = localStorage.getItem('nickname') || '';
if (!nick){ setMsg('scoreMsg', '먼저 위에서 별명을 저장해 주세요.', false); return; }
if (!game){ setMsg('scoreMsg', '어느 게임인지 적어 주세요.', false); return; }
const score = Number(raw);
if (!raw || !Number.isFinite(score) || score < 0 || score > 1000000){
setMsg('scoreMsg', '점수는 0부터 1000000 사이의 숫자로 적어 주세요.', false); return;
}
const key = game.replace(/[\/\s]+/g, '_').slice(0, 30);
try {
await fs.setDoc(fs.doc(db, 'ranking', key + '__' + uid), {
uid, nickname: nick, game: game.slice(0, 30), score: Math.round(score), at: Date.now()
});
setMsg('scoreMsg', '점수를 올렸습니다.', true);
await loadRanking();
} catch (e){
setMsg('scoreMsg', '올리지 못했습니다: ' + e.code, false);
console.error(e);
}
});
}
/* 등록된 작품 이름을 점수 칸의 후보 목록으로 */
let GAMES = [];
function fillGameList(){
const dl = el('gamelist');
if (!dl) return;
dl.innerHTML = '';
GAMES.forEach((t) => {
const o = document.createElement('option');
o.value = t;
dl.appendChild(o);
});
}
async function loadGallery(){
const wrap = el('cards');
wrap.innerHTML = '';
let docs = [];
try {
const snap = await fs.getDocs(fs.collection(db, 'gallery'));
snap.forEach((d) => docs.push(d.data()));
} catch (e){ console.error(e); }
docs.sort((a, b) => (b.at || 0) - (a.at || 0));
el('galEmpty').classList.toggle('hide', docs.length > 0);
GAMES = docs.map((w) => w.title).filter(Boolean);
fillGameList();
docs.forEach((w) => {
const c = document.createElement('div');
c.className = 'card';
const h = document.createElement('h3'); h.textContent = w.title || '이름 없는 게임';
const by = document.createElement('div'); by.className = 'by'; by.textContent = '만든 사람 · ' + (w.nickname || '익명');
const p = document.createElement('p'); p.textContent = w.desc || '';
c.append(h, by, p);
if (w.url && /^https?:\/\//i.test(w.url)){
const a = document.createElement('a');
a.href = w.url; a.target = '_blank'; a.rel = 'noopener noreferrer';
a.textContent = '해 보기';
c.appendChild(a);
}
wrap.appendChild(c);
});
}
async function loadRanking(){
const wrap = el('ranks');
wrap.innerHTML = '';
let rows = [];
try {
const snap = await fs.getDocs(fs.collection(db, 'ranking'));
snap.forEach((d) => rows.push(d.data()));
} catch (e){ console.error(e); }
el('rankEmpty').classList.toggle('hide', rows.length > 0);
const byGame = {};
rows.forEach((r) => {
const g = r.game || '이름 없는 게임';
(byGame[g] = byGame[g] || []).push(r);
});
Object.keys(byGame).sort().forEach((g) => {
const list = byGame[g].sort((a, b) => (b.score || 0) - (a.score || 0));
const box = document.createElement('div');
box.className = 'rank';
const h = document.createElement('h3');
h.innerHTML = '';
const t = document.createElement('span'); t.style.fontWeight = '700';
t.style.fontSize = '17px'; t.style.color = 'var(--ink)'; t.textContent = g;
const c = document.createElement('span'); c.textContent = list.length + '명 참가';
h.append(t, c);
box.appendChild(h);
list.slice(0, 5).forEach((r, i) => {
const row = document.createElement('div');
row.className = 'rk' + (i === 0 ? ' top' : '') + (r.uid === uid ? ' me' : '');
const n = document.createElement('span'); n.className = 'n'; n.textContent = (i + 1);
const w = document.createElement('span'); w.className = 'who2'; w.textContent = r.nickname || '익명';
const s = document.createElement('span'); s.className = 'sc'; s.textContent = r.score;
row.append(n, w, s);
box.appendChild(row);
});
const mineIdx = list.findIndex((r) => r.uid === uid);
if (mineIdx >= 5){
const row = document.createElement('div');
row.className = 'rk me';
const n = document.createElement('span'); n.className = 'n'; n.textContent = (mineIdx + 1);
const w = document.createElement('span'); w.className = 'who2'; w.textContent = list[mineIdx].nickname || '나';
const s = document.createElement('span'); s.className = 'sc'; s.textContent = list[mineIdx].score;
row.append(n, w, s);
box.appendChild(row);
}
wrap.appendChild(box);
});
}
</script>
</body>
</html>
오늘은 선생님이 올려 줄 게 없습니다. 내 저장소에 내가 올리고, 내 주소를 전시관에 등록합니다.
올리기 전 점검
파일 이름에 한글·띄어쓰기 없이 영문으로. 예: game.html
파일 안에 실명·연락처·학교 이름이 없는지 한 번 더 확인.
올린 뒤 내 주소를 직접 눌러 실제로 열리는지 확인. 여기서 확인하지 않으면 전시관에 죽은 주소를 올리게 됩니다.
전시관에서 할 일
1. 구글 로그인 → 2. 별명 저장 → 3. 게임 이름·설명·내 게임 주소 등록 → 4. 친구 게임 해 보기 → 5. 내 점수 등록
전시관은 선생님 것입니다. 내 프로젝트가 아니라 반 전체가 함께 쓰는 곳이라, 여기서는 설정값을 넣을 일이 없습니다. 로그인만 하면 됩니다.
남은 시간은 자유 플레이로
마지막 20분은 아무것도 시키지 말고 서로의 게임을 하게 두세요. 이 수업의 보상은 거기에 있습니다.
game.html으로 바꿔 내 저장소에 업로드 → Commit → 내주소/game.html으로 열어 보기올리는 법프롬프트 작성 공식
도구가 무엇이든 이 네 가지를 말해 주면 원하는 것에 가까워집니다.
무엇을 만들지, 어디서 돌아가야 하는지.
"파일 하나로 완결되게, 외부 라이브러리 없이"화면 구성, 색, 글자 크기, 휴대폰 대응.
"맨 위에 점수를 크게, 휴대폰에서도 가로 스크롤 없이"무엇을 누르면 무엇이 일어나는지, 예외는 어떻게.
"폭탄을 누르면 15점이 깎이되 0점 밑으로는 안 내려가게"저장할지, 새로고침해도 남아야 하는지.
"최고 점수만 localStorage에 저장해 줘"고칠 때는 짧게
캔버스에서는 전체 코드를 다시 달라고 할 필요가 없습니다. 고칠 부분만 말하면 그 자리에서 바뀝니다. 이 수업에서 가장 많이 하게 될 일입니다.
제목을 더 크게 해 줘.
버튼을 눌러도 아무 일도 안 일어나. 왜 그런지 찾아서 고쳐 줘.
폭탄이 너무 자주 나와. 조금 줄여 줘.
휴대폰에서 가로로 밀려. 세로로 접히게 고쳐 줘.대화를 닫지 마세요
같은 결과물을 다음 차시에 이어서 고칩니다. 제미나이의 그 대화를 다시 열면 만들던 것이 그대로 있습니다. 새 대화에서 시작하면 처음부터 다시 만들어야 합니다.