팀 오목
레드 팀과 블루 팀이 휴대폰으로 함께 두는 실시간 오목
- 로비에서 팀을 고르고 방장이 시작
- 팀 안에서 한 명씩 돌아가며 두기
- 삼삼(3-3) 금지 자리 표시
Apps Script + 캐시 · 1.5초마다 판을 맞춰요
▶ 바로 해 보기Apps Script로 만드는 나만의 웹앱
들어가기
이 파일은 HTML이라고 알려 줘요
화면에 안 보이는 설정한글 설정, 브라우저 탭 제목, CSS
화면에 보이는 모든 것제목, 버튼, 입력칸, 그리고 JS
직접 살펴보기 · 지금 이 화면도 HTML이에요
CSS는 HTML 요소의 색 · 크기 · 모양을 정해요.
직접 살펴보기 · CSS 값 바꿔 보기
.css-demo 찾기class 이름 앞에 점(.)을 붙여 CSS를 적어요#1A5FD0은 red로, 20px은 40px로값을 눌러 고치면 버튼이 바로 바뀌어요 · 새로고침하면 원래대로JS는 누르면 화면이 반응하게 만들어요.
직접 살펴보기 · 버튼에 붙은 JS 찾기
다른 곳까지 바뀔 수 있어요멀쩡하던 기능이 사라지기도 해요
그 부분만 바뀌어요무엇이 달라졌는지 확인하기도 쉬워요
"아래 코드에서 버튼 색만 초록색으로 바꿔 줘"+ 복사한 코드 붙여넣기
이따가 AI로 웹앱을 고칠 때
자바스크립트를 기반으로 구글 스프레드시트, 문서, Gmail 등 구글 서비스를 자동화하고 기능을 확장할 수 있는 개발 도구입니다.
익숙한 언어로 작성
내 컴퓨터에서 안 돌려도 됨
시트 · 메일 · 캘린더 · 폼
구글 계정만 있으면 OK
브라우저에서 바로 개발
반복 작업을 알아서 처리
링크 하나로 웹앱 배포
기능을 만드는 코드 (.gs)
화면을 만드는 웹페이지
페이지를 여는 역할
화면을 만드는 역할
// Spin the Wheel app (no spreadsheet connection)
// 2 files: Code.gs (opens the page) + index.html (wheel screen and behavior)
function doGet() {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('Spin the Wheel')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
Code.gs가 'index'라는 파일을 찾아 화면으로 보여 줘요
파일을 찾지 못해 오류가 나요Index, index 1도 다른 이름
이름 칸에는 index만 적어요.html은 편집기가 붙여요
아니에요. 두 곳을 똑같이 바꾸면 돼요파일 wheel이면 Code.gs도 'wheel'
function doGet() {
return HtmlService
.createHtmlOutputFromFile('index')
}
// 'index' ↔ 파일 목록의 index.html
✓ index → index.html
✗ Index → 대문자는 다른 이름
✗ index.html → index.html.html
<!DOCTYPE html>
<html lang="en">
<head>
<base target="_top">
<meta charset="UTF-8">
<style>
* { box-sizing: border-box; }
body {
margin: 0; background: #F2F5FA; color: #1C2434;
font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
}
.wrap { max-width: 720px; margin: 0 auto; padding: 24px 16px 48px; text-align: center; }
h1 { font-size: 28px; margin: 0 0 12px; letter-spacing: -0.02em; }
canvas { width: 100%; max-width: 360px; height: auto; }
button {
font: inherit; font-weight: 700; cursor: pointer;
padding: 12px 32px; border: 0; border-radius: 10px;
background: #1A5FD0; color: #fff; font-size: 18px;
}
button:disabled { opacity: .5; cursor: default; }
button:focus-visible, textarea:focus-visible { outline: 3px solid #F29900; outline-offset: 2px; }
#result { min-height: 3rem; margin: 16px 0 0; font-size: 28px; font-weight: 900; }
label { display: block; margin: 20px 0 6px; font-weight: 700; }
textarea {
width: 100%; max-width: 360px; padding: 10px 12px; font: inherit;
border: 1px solid #D9DFEA; border-radius: 8px; resize: vertical;
}
</style>
</head>
<body>
<div class="wrap">
<h1>Spin the Wheel</h1>
<canvas id="wheel" aria-label="Spinning wheel"></canvas>
<div><button id="spin" type="button" onclick="spin()">Spin</button></div>
<p id="result" role="status"></p>
<label for="items">Items (one per line)</label>
<textarea id="items" rows="7">Ana
Luis
Sofia
Mateo
Camila
Daniel</textarea>
</div>
<script>
var W = 360, H = 380, CX = 180, CY = 200, R = 150;
var COLORS = ['#1A73E8', '#D93025', '#188038', '#7B3FBF', '#00796B', '#C25E00'];
var FONT_FAMILY = 'system-ui, "Segoe UI", Roboto, Arial, sans-serif';
var FONT = 'bold 18px ' + FONT_FAMILY;
var canvas = document.getElementById('wheel');
var ctx = canvas.getContext('2d');
var dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);
var items = [];
var rot = 0;
var spinning = false;
function readItems() {
items = document.getElementById('items').value
.split('\n')
.map(function (t) { return t.trim(); })
.filter(function (t) { return t; });
}
function draw() {
ctx.clearRect(0, 0, W, H);
if (items.length < 2) {
ctx.beginPath();
ctx.arc(CX, CY, R, 0, Math.PI * 2);
ctx.fillStyle = '#D9DFEA';
ctx.fill();
ctx.fillStyle = '#5D6879';
ctx.font = FONT;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Enter at least 2 items', CX, CY);
return;
}
var seg = (Math.PI * 2) / items.length;
// Shrink the label font as slices get thinner (18px max, 8px min)
var fontSize = Math.max(8, Math.min(18, Math.floor(R * 0.55 * seg)));
var maxChars = fontSize >= 14 ? 10 : 14;
items.forEach(function (text, i) {
var a0 = rot + i * seg;
var a1 = a0 + seg;
var c = i % COLORS.length;
if (i === items.length - 1 && c === 0 && i > 0) c = 2; // avoid matching the first slice's color
ctx.beginPath();
ctx.moveTo(CX, CY);
ctx.arc(CX, CY, R, a0, a1);
ctx.closePath();
ctx.fillStyle = COLORS[c];
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
ctx.save();
ctx.translate(CX, CY);
ctx.rotate(a0 + seg / 2);
ctx.fillStyle = '#fff';
ctx.font = 'bold ' + fontSize + 'px ' + FONT_FAMILY;
ctx.textAlign = 'right';
ctx.textBaseline = 'middle';
ctx.fillText(text.length > maxChars ? text.slice(0, maxChars) + '…' : text, R - 16, 0);
ctx.restore();
});
// Center circle
ctx.beginPath();
ctx.arc(CX, CY, 18, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
// Pointer at the top
ctx.beginPath();
ctx.moveTo(CX - 14, 22);
ctx.lineTo(CX + 14, 22);
ctx.lineTo(CX, 62);
ctx.closePath();
ctx.fillStyle = '#172033';
ctx.fill();
}
function spin() {
if (spinning) return;
readItems();
if (items.length < 2) { draw(); return; }
spinning = true;
document.getElementById('spin').disabled = true;
document.getElementById('result').textContent = '';
var start = rot;
var total = Math.PI * 2 * (5 + Math.random() * 3); // 5–8 turns plus a random angle
var duration = 4000;
var t0 = null;
function step(ts) {
if (t0 === null) t0 = ts;
var p = Math.min((ts - t0) / duration, 1);
var eased = 1 - Math.pow(1 - p, 3); // slow down gradually
rot = start + total * eased;
draw();
if (p < 1) {
requestAnimationFrame(step);
} else {
finish();
}
}
requestAnimationFrame(step);
}
function finish() {
var seg = (Math.PI * 2) / items.length;
var pointer = Math.PI * 1.5; // the pointer sits at the top (12 o'clock)
var angle = (((pointer - rot) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
var index = Math.floor(angle / seg);
document.getElementById('result').textContent = '🎉 ' + items[index];
document.getElementById('spin').disabled = false;
spinning = false;
}
document.getElementById('items').addEventListener('input', function () {
if (spinning) return;
readItems();
document.getElementById('result').textContent = '';
draw();
});
readItems();
draw();
</script>
</body>
</html>
| 몇 번째 줄 | 지금 | 이렇게 바꿔 보기 | 무엇이 바뀌나 |
|---|---|---|---|
| 33index.html | Spin the Wheel | 우리 반 돌림판 | HTML맨 위 제목 글자 |
| 35index.html | >Spin< | >돌려라!< | HTML버튼 글자 (> < 사이만 바꿔요) |
| 19index.html | #1A5FD0 | #E91E63 | CSS버튼 색 (파랑 → 분홍) |
| 144index.html | 4000 | 8000 | JS도는 시간 (4초 → 8초, 더 두근두근) |
모두 index.html 에서 바꿔요. 바꾼 뒤 Ctrl + S → 배포 → 배포 관리 → 연필(수정) → 버전: 새 버전 → 배포. 그다음 웹앱 주소를 새로고침하면 바뀌어 있어요.
누구나
선생님만
선생님만
게시글을 저장할 데이터베이스
서버 구축과 설정에 시간과 지식이 필요
구글 스프레드시트를 데이터베이스로 활용
| No. | Name | Title | Content | Comments |
|---|---|---|---|---|
| 1 | 민준 | 안녕 | 첫 글이에요! | 환영해요! |
| 2 | 서연 | 질문 | 숙제가 뭐예요? |
한 줄이 글 하나, 사용자가 글을 남기면 새 줄이 생겨요
이렇게 열면 스프레드시트와 자동으로 연결됩니다.
// Class Board (Google Sheets + Apps Script practice)
//
// - Writing a post: anyone
// - Reading posts / commenting / deleting: teacher only (password required)
// ★ Be sure to change this to a password only you know.
const TEACHER_PASSWORD = '1234';
// Spreadsheet ID (optional)
// If you opened this from the spreadsheet via [Extensions → Apps Script], leave it empty.
// If you created the project directly at script.google.com, paste the long string
// between /d/ and /edit in the spreadsheet's URL here.
const SPREADSHEET_ID = '';
// Name of the sheet where posts are stored (created automatically if missing)
const SHEET_NAME = 'Board';
// Time zone
const TIME_ZONE = 'Asia/Seoul';
// ---------------------------------------------------------------
// Open the web app
// ---------------------------------------------------------------
function doGet() {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('Class Board')
.addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
// ---------------------------------------------------------------
// What anyone can do: leave a post
// ---------------------------------------------------------------
function addPost(name, title, content) {
name = clean_(name, 20, 'Name');
title = clean_(title, 50, 'Title');
content = clean_(content, 1000, 'Content');
const lock = LockService.getScriptLock();
lock.waitLock(10000); // handle posts one at a time so simultaneous writes don't collide
try {
const sheet = getSheet_();
const row = sheet.getLastRow() + 1;
const id = row === 2 ? 1 : Number(sheet.getRange(row - 1, 1).getValue()) + 1;
const time = Utilities.formatDate(new Date(), TIME_ZONE, 'yyyy-MM-dd HH:mm');
const range = sheet.getRange(row, 1, 1, 6);
range.setNumberFormat('@'); // store as plain text (text starting with = or + won't become a formula)
range.setValues([[String(id), time, name, title, content, '']]);
} finally {
lock.releaseLock();
}
return true;
}
// ---------------------------------------------------------------
// What only the teacher can do
// ---------------------------------------------------------------
// Read the list of posts (newest first)
function getPosts(password) {
checkTeacher_(password);
const sheet = getSheet_();
const lastRow = sheet.getLastRow();
if (lastRow < 2) return [];
const values = sheet.getRange(2, 1, lastRow - 1, 6).getValues();
return values
.map(function (r) {
return {
id: String(r[0]),
time: String(r[1]),
name: String(r[2]),
title: String(r[3]),
content: String(r[4]),
comment: String(r[5])
};
})
.reverse();
}
// Add a comment (multiple comments stack up underneath)
function addComment(password, id, text) {
checkTeacher_(password);
text = clean_(text, 300, 'Comment');
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const sheet = getSheet_();
const row = findRow_(sheet, id);
if (row === -1) throw new Error('Post not found. It may have already been deleted.');
const cell = sheet.getRange(row, 6);
const stamp = Utilities.formatDate(new Date(), TIME_ZONE, 'MM-dd HH:mm');
const line = '[' + stamp + '] ' + text;
const old = String(cell.getValue());
cell.setNumberFormat('@');
cell.setValue(old ? old + '\n' + line : line);
} finally {
lock.releaseLock();
}
return true;
}
// Delete a post
function deletePost(password, id) {
checkTeacher_(password);
const lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
const sheet = getSheet_();
const row = findRow_(sheet, id);
if (row === -1) throw new Error('Post not found. It may have already been deleted.');
sheet.deleteRow(row);
} finally {
lock.releaseLock();
}
return true;
}
// ---------------------------------------------------------------
// Helper functions (a name ending in _ can't be called from the page)
// ---------------------------------------------------------------
// Check the password — throws an error if wrong, so the code below it never runs
function checkTeacher_(password) {
if (password !== TEACHER_PASSWORD) {
throw new Error('Incorrect password.');
}
}
// Get the board sheet (creates it with a header row if missing)
function getSheet_() {
const ss = SPREADSHEET_ID
? SpreadsheetApp.openById(SPREADSHEET_ID)
: SpreadsheetApp.getActiveSpreadsheet();
if (!ss) {
throw new Error('Spreadsheet not found. Check SPREADSHEET_ID at the top of Code.gs.');
}
let sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(SHEET_NAME);
sheet.appendRow(['No.', 'Date', 'Name', 'Title', 'Content', 'Comments']);
sheet.setFrozenRows(1);
}
return sheet;
}
// Find the row number of a post by its id (-1 if not found)
function findRow_(sheet, id) {
const lastRow = sheet.getLastRow();
if (lastRow < 2) return -1;
const ids = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
for (let i = 0; i < ids.length; i++) {
if (String(ids[i][0]) === String(id)) return i + 2;
}
return -1;
}
// Tidy up input: trim spaces, check it's not empty or too long
function clean_(value, max, label) {
const text = String(value || '').trim();
if (!text) throw new Error('Please fill in the ' + label.toLowerCase() + '.');
if (text.length > max) throw new Error(label + ' can be up to ' + max + ' characters.');
return text;
}
<!DOCTYPE html>
<html lang="en">
<head>
<base target="_top">
<meta charset="UTF-8">
<style>
:root {
--bg: #F2F5FA;
--card: #FFFFFF;
--ink: #1C2434;
--muted: #5D6879;
--line: #D9DFEA;
--accent: #1A5FD0;
--accent-soft: #E3ECFC;
--danger: #C5221F;
--note: #FFF6D6;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
}
.wrap { max-width: 720px; margin: 0 auto; padding: 24px 16px 64px; }
h1 { font-size: 28px; margin: 0 0 16px; letter-spacing: -0.02em; }
.tabs { display: flex; gap: 8px; margin-bottom: 16px; }
.tab {
flex: 1; padding: 12px; font: inherit; font-weight: 700;
border: 1px solid var(--line); background: var(--card); color: var(--muted);
border-radius: 10px; cursor: pointer;
}
.tab.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.panel { background: var(--card); border: 1px solid var(--line); border-radius: 14px; padding: 20px; }
label { display: block; font-weight: 700; margin: 14px 0 6px; }
label:first-child { margin-top: 0; }
input, textarea {
width: 100%; padding: 10px 12px; font: inherit; color: inherit;
background: var(--card); border: 1px solid var(--line); border-radius: 8px;
}
textarea { resize: vertical; }
button { font: inherit; cursor: pointer; }
button:focus-visible, input:focus-visible, textarea:focus-visible {
outline: 3px solid #F29900; outline-offset: 2px;
}
.primary {
margin-top: 16px; padding: 12px 22px; font-weight: 700;
background: var(--accent); color: #fff; border: 0; border-radius: 8px;
}
.primary:disabled { opacity: .5; cursor: default; }
.msg { margin: 12px 0 0; min-height: 1.6em; font-weight: 500; }
.msg.ok { color: #188038; }
.msg.err { color: var(--danger); }
.bar { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 12px; }
.bar button {
padding: 6px 12px; background: var(--card); border: 1px solid var(--line);
border-radius: 8px; margin-left: 6px;
}
.post { border-top: 1px solid var(--line); padding: 18px 0; }
.post:first-child { border-top: 0; padding-top: 0; }
.post-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; }
.post-head h2 { font-size: 20px; margin: 0; letter-spacing: -0.01em; }
.del {
flex: none; padding: 4px 12px; background: var(--card);
color: var(--danger); border: 1px solid var(--danger); border-radius: 8px;
}
.meta { margin: 2px 0 10px; color: var(--muted); font-size: 14px; }
.meta span + span { margin-left: 12px; }
.body { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
.comment {
margin: 12px 0 0; padding: 10px 12px; background: var(--note);
border-radius: 8px; white-space: pre-wrap; overflow-wrap: anywhere; font-size: 15px;
}
.cform { display: flex; gap: 8px; margin-top: 12px; }
.cform button {
flex: none; padding: 0 14px; background: var(--accent-soft); color: var(--accent);
border: 0; border-radius: 8px; font-weight: 700;
}
.empty { text-align: center; color: var(--muted); padding: 24px 0; margin: 0; }
</style>
</head>
<body>
<div class="wrap">
<h1>Class Board Practice</h1>
<div class="tabs">
<button id="tab-write" class="tab on" type="button" onclick="showTab('write')">Write</button>
<button id="tab-teacher" class="tab" type="button" onclick="showTab('teacher')">Teacher Only</button>
</div>
<!-- Anyone: write a post -->
<section id="panel-write" class="panel">
<label for="name">Name</label>
<input id="name" maxlength="20" autocomplete="off">
<label for="title">Title</label>
<input id="title" maxlength="50" autocomplete="off">
<label for="content">Content</label>
<textarea id="content" rows="6" maxlength="1000"></textarea>
<button id="send" class="primary" type="button" onclick="sendPost()">Post</button>
<p id="write-msg" class="msg" role="status"></p>
</section>
<!-- Teacher only: read · comment · delete -->
<section id="panel-teacher" class="panel" hidden>
<div id="login">
<label for="pw">Teacher password</label>
<input id="pw" type="password" autocomplete="off" onkeydown="if (event.key === 'Enter') login()">
<button class="primary" type="button" onclick="login()">Enter</button>
<p id="login-msg" class="msg" role="status"></p>
</div>
<div id="admin" hidden>
<div class="bar">
<strong id="count"></strong>
<span>
<button type="button" onclick="loadPosts()">Refresh</button>
<button type="button" onclick="logout()">Log out</button>
</span>
</div>
<p id="admin-msg" class="msg" role="status"></p>
<div id="list"></div>
</div>
</section>
</div>
<script>
// After logging in, the password is kept here briefly and sent with every request to the server.
// (The server only sends posts back when the password is correct.)
var pw = '';
function $(id) { return document.getElementById(id); }
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text; // textContent: any HTML in a post is shown as text, never run
return node;
}
function setMsg(id, text, type) {
var node = $(id);
node.textContent = text || '';
node.className = 'msg' + (type ? ' ' + type : '');
}
function errText(e) {
return (e && e.message ? e.message : String(e)).replace(/^Error:\s*/, '');
}
// ---------- Tabs ----------
function showTab(name) {
$('panel-write').hidden = name !== 'write';
$('panel-teacher').hidden = name !== 'teacher';
$('tab-write').className = 'tab' + (name === 'write' ? ' on' : '');
$('tab-teacher').className = 'tab' + (name === 'teacher' ? ' on' : '');
}
// ---------- Write a post (anyone) ----------
function sendPost() {
var name = $('name').value, title = $('title').value, content = $('content').value;
if (!name.trim() || !title.trim() || !content.trim()) {
setMsg('write-msg', 'Please fill in your name, title, and content.', 'err');
return;
}
$('send').disabled = true;
setMsg('write-msg', 'Sending…');
google.script.run
.withSuccessHandler(function () {
$('send').disabled = false;
$('name').value = ''; $('title').value = ''; $('content').value = '';
setMsg('write-msg', 'Your post has been submitted!', 'ok');
})
.withFailureHandler(function (e) {
$('send').disabled = false;
setMsg('write-msg', errText(e), 'err');
})
.addPost(name, title, content);
}
// ---------- Teacher login ----------
function login() {
var input = $('pw').value;
if (!input) { setMsg('login-msg', 'Please enter the password.', 'err'); return; }
setMsg('login-msg', 'Checking…');
google.script.run
.withSuccessHandler(function (posts) {
pw = input;
$('pw').value = '';
setMsg('login-msg', '');
$('login').hidden = true;
$('admin').hidden = false;
render(posts);
})
.withFailureHandler(function (e) { setMsg('login-msg', errText(e), 'err'); })
.getPosts(input);
}
function logout() {
pw = '';
$('admin').hidden = true;
$('login').hidden = false;
$('list').textContent = '';
setMsg('admin-msg', '');
}
// ---------- Post list ----------
function loadPosts() {
setMsg('admin-msg', 'Loading…');
google.script.run
.withSuccessHandler(function (posts) { setMsg('admin-msg', ''); render(posts); })
.withFailureHandler(function (e) { setMsg('admin-msg', errText(e), 'err'); })
.getPosts(pw);
}
function render(posts) {
$('count').textContent = posts.length + (posts.length === 1 ? ' post' : ' posts');
var list = $('list');
list.textContent = '';
if (!posts.length) {
list.appendChild(el('p', 'empty', 'No posts yet.'));
return;
}
posts.forEach(function (p) { list.appendChild(buildPost(p)); });
}
function buildPost(p) {
var art = el('article', 'post');
var head = el('div', 'post-head');
head.appendChild(el('h2', '', p.title));
var del = el('button', 'del', 'Delete');
del.type = 'button';
del.onclick = function () { removePost(p.id); };
head.appendChild(del);
art.appendChild(head);
var meta = el('p', 'meta');
meta.appendChild(el('span', '', p.name));
meta.appendChild(el('span', '', p.time));
art.appendChild(meta);
art.appendChild(el('p', 'body', p.content));
if (p.comment) art.appendChild(el('p', 'comment', p.comment));
var form = el('div', 'cform');
var input = document.createElement('input');
input.placeholder = 'Add a comment';
input.maxLength = 300;
input.setAttribute('aria-label', 'Comment');
var btn = el('button', '', 'Add');
btn.type = 'button';
btn.onclick = function () { sendComment(p.id, input.value, btn); };
input.onkeydown = function (e) { if (e.key === 'Enter') btn.click(); };
form.appendChild(input);
form.appendChild(btn);
art.appendChild(form);
return art;
}
// ---------- Comment · delete (teacher only) ----------
function sendComment(id, text, btn) {
if (!text.trim()) return;
btn.disabled = true;
google.script.run
.withSuccessHandler(function () { loadPosts(); })
.withFailureHandler(function (e) { btn.disabled = false; setMsg('admin-msg', errText(e), 'err'); })
.addComment(pw, id, text);
}
function removePost(id) {
if (!confirm('Delete this post? This cannot be undone.')) return;
google.script.run
.withSuccessHandler(function () { loadPosts(); })
.withFailureHandler(function (e) { setMsg('admin-msg', errText(e), 'err'); })
.deletePost(pw, id);
}
</script>
</body>
</html>
배포 전 확인! Code.gs 6번째 줄 비밀번호가 아직 '1234'인가요? 나만 아는 비밀번호로 바꾸고 저장하세요.
코드를 고친 뒤에는 배포 관리 → 수정 → 새 버전으로 다시 배포합니다.
앱에서 Google 계정의 민감한 정보에 액세스하려고 합니다. 개발자가 Google에 확인을 요청하기 전까지는 이 앱을 사용하지 마세요.
Google에서 아직 이 앱을 검토하지 않았으므로 앱의 진위를 확인할 수 없습니다.
제목 없는 프로젝트(으)로 이동(안전하지 않음)3내가 직접 만든 앱이라 안전해요. 구글이 심사하지 않았다는 뜻일 뿐이에요. 처음 한 번만 승인하면 됩니다.
나만 접근 (일반 액세스: 제한됨)
웹앱 주소로만 접속 (시트 권한 불필요)
모든 사용자
사방에서 날아오는 미사일을 최대한 오래 피하기
이름과 생존 시간을 스프레드시트에 기록
가장 오래 살아남은 순서로 확인
누가 얼마나 오래 버텼는지 기록할 곳
기록을 매번 손으로 적어 두기 번거로움
구글 스프레드시트를 랭킹판으로 활용
첫 게임을 하면 Records 시트가 자동으로 만들어져요.
var SHEET_NAME = 'Records';
var TOP_N = 10;
function doGet() {
return HtmlService.createHtmlOutputFromFile('index')
.setTitle('Missile Dodge')
.addMetaTag('viewport', 'width=device-width, initial-scale=1, user-scalable=no');
}
// Works both as a sheet-bound script and as a standalone script
// (standalone: creates a spreadsheet once and remembers its ID).
function getSpreadsheet_() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
if (ss) return ss;
var props = PropertiesService.getScriptProperties();
var id = props.getProperty('SPREADSHEET_ID');
if (id) return SpreadsheetApp.openById(id);
ss = SpreadsheetApp.create('Missile Dodge Records');
props.setProperty('SPREADSHEET_ID', ss.getId());
return ss;
}
function getSheet_() {
var ss = getSpreadsheet_();
var sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) {
sheet = ss.insertSheet(SHEET_NAME);
sheet.appendRow(['Name', 'Time (s)', 'Date']);
sheet.setFrozenRows(1);
sheet.getRange('A:A').setNumberFormat('@'); // keep names as plain text
}
return sheet;
}
// Saves one run and returns the updated ranking plus this player's position.
function saveTime(name, seconds) {
// Strip leading = + - @ so a name can never become a formula.
var safeName = String(name || '').replace(/^[\s=+\-@]+/, '').trim().slice(0, 20) || 'Anonymous';
var safeTime = Math.max(0, Math.round((Number(seconds) || 0) * 10) / 10);
var lock = LockService.getScriptLock();
lock.waitLock(10000);
try {
getSheet_().appendRow([safeName, safeTime, new Date()]);
SpreadsheetApp.flush();
} finally {
lock.releaseLock();
}
return buildRanking_(safeName);
}
function getRanking() {
return buildRanking_(null);
}
// Best time per player, longest survival first; ties go to whoever got there first.
function buildRanking_(playerName) {
var sheet = getSheet_();
var lastRow = sheet.getLastRow();
var best = {};
if (lastRow >= 2) {
var rows = sheet.getRange(2, 1, lastRow - 1, 3).getValues();
rows.forEach(function (r) {
var name = String(r[0]).trim();
var time = Number(r[1]);
var at = r[2] instanceof Date ? r[2].getTime() : 0;
if (!name || isNaN(time)) return;
var cur = best[name];
if (!cur || time > cur.time || (time === cur.time && at < cur.at)) {
best[name] = { name: name, time: time, at: at };
}
});
}
var list = Object.keys(best).map(function (k) { return best[k]; });
list.sort(function (a, b) { return b.time - a.time || a.at - b.at; });
var myRank = 0, myBest = 0;
if (playerName) {
for (var i = 0; i < list.length; i++) {
if (list[i].name === playerName) { myRank = i + 1; myBest = list[i].time; break; }
}
}
// Only plain strings/numbers: google.script.run returns null if a Date is included.
return {
top: list.slice(0, TOP_N).map(function (e) { return { name: e.name, time: e.time }; }),
total: list.length,
me: playerName || '',
myRank: myRank,
myBest: myBest
};
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>
:root {
--bg: #EEF2F8; --card: #FFFFFF; --line: #DDE3EE; --text: #172033; --muted: #5A6577;
--accent: #1A5FD0; --accent-soft: #E6EEFC; --danger: #D93025; --gold: #F2B600;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, 'Segoe UI', 'Malgun Gothic', sans-serif; background: var(--bg); color: var(--text); height: 100vh; height: 100dvh; display: flex; justify-content: center; overflow: hidden; -webkit-tap-highlight-color: transparent; }
#app { width: 100%; max-width: 420px; height: 100%; display: flex; flex-direction: column; }
.screen { flex: 1; min-height: 0; display: none; flex-direction: column; align-items: center; justify-content: center; padding: 20px 16px; }
.screen.show { display: flex; }
.card { width: 100%; background: var(--card); border: 1px solid var(--line); border-radius: 18px; padding: 26px 22px; display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; box-shadow: 0 8px 28px rgba(23, 32, 51, 0.06); max-height: 100%; overflow-y: auto; }
.logo { width: 56px; height: 56px; border-radius: 16px; background: var(--accent-soft); display: flex; align-items: center; justify-content: center; font-size: 28px; }
h1, h2 { margin: 0; letter-spacing: -0.02em; }
h1 { font-size: 26px; }
h2 { font-size: 15px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.08em; }
.hint { margin: 0; color: var(--muted); font-size: 14px; line-height: 1.5; }
input { font: inherit; font-size: 16px; padding: 12px 14px; border-radius: 12px; border: 1px solid var(--line); background: #F8FAFD; color: var(--text); width: 100%; text-align: center; outline: none; transition: border-color .15s, box-shadow .15s; }
input:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.btn { font: inherit; font-size: 16px; font-weight: 700; padding: 13px 20px; border-radius: 12px; border: 0; background: var(--accent); color: #fff; cursor: pointer; width: 100%; transition: filter .15s, transform .05s; }
.btn:hover { filter: brightness(1.08); }
.btn:active { transform: scale(0.98); }
.btn.ghost { background: transparent; color: var(--muted); border: 1px solid var(--line); font-weight: 600; }
.btn-row { display: flex; gap: 10px; width: 100%; }
/* Leaderboard */
.board { width: 100%; text-align: left; }
.board-head { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin: 0 4px 6px; }
.board ol { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
.board li { display: flex; align-items: center; gap: 10px; padding: 8px 12px; border-radius: 10px; background: #F8FAFD; font-size: 14px; }
.board li.me { background: var(--accent-soft); outline: 1px solid var(--accent); }
.board .rk { width: 24px; text-align: center; font-weight: 700; color: var(--muted); flex: none; }
.board .nm { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; }
.board .tm { font-variant-numeric: tabular-nums; font-weight: 700; }
.board .msg { padding: 14px; text-align: center; color: var(--muted); font-size: 14px; background: #F8FAFD; border-radius: 10px; }
.board .msg.err { color: var(--danger); }
/* Game */
#gameScreen { justify-content: flex-start; gap: 10px; padding: 14px 16px 16px; }
#hud { flex: none; width: 100%; display: flex; justify-content: space-between; align-items: center; }
.pill { background: var(--card); border: 1px solid var(--line); border-radius: 999px; padding: 6px 14px; font-size: 14px; color: var(--muted); font-variant-numeric: tabular-nums; }
.pill b { color: var(--text); font-size: 18px; }
#stage { flex: 1; min-height: 0; width: 100%; display: flex; align-items: center; justify-content: center; }
canvas { display: block; touch-action: none; background: #FBFCFE; border: 1px solid var(--line); border-radius: 16px; box-shadow: 0 8px 28px rgba(23, 32, 51, 0.06); cursor: crosshair; }
/* Game over */
.big-time { font-size: 56px; font-weight: 800; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; line-height: 1; }
.big-time small { font-size: 22px; color: var(--muted); font-weight: 700; margin-left: 2px; }
#rankLine { margin: 0; font-size: 14px; color: var(--muted); min-height: 1.4em; }
#rankLine strong { color: var(--accent); }
.badge { display: none; font-size: 12px; font-weight: 700; color: #7A5B00; background: #FFF4CC; border-radius: 999px; padding: 4px 10px; }
.badge.show { display: inline-block; }
</style>
</head>
<body>
<div id="app">
<div id="startScreen" class="screen show">
<div class="card">
<div class="logo">🚀</div>
<h1>Missile Dodge</h1>
<p class="hint">Drag to move your ship and dodge the missiles.<br>The longer you survive, the higher you rank.</p>
<input id="nameInput" placeholder="Enter your name" maxlength="10" autocomplete="off">
<button id="startBtn" class="btn">Start</button>
<div class="board">
<div class="board-head"><span>🏆 Top survivors</span><span id="startTotal"></span></div>
<ol id="startBoard"><li class="msg">Loading ranking…</li></ol>
</div>
</div>
</div>
<div id="gameScreen" class="screen">
<div id="hud">
<span class="pill">⏱ <b id="timeLabel">0.0</b> s</span>
<span class="pill" id="bestPill">Best <b id="bestLabel">–</b></span>
</div>
<div id="stage"><canvas id="canvas" width="340" height="520"></canvas></div>
</div>
<div id="overScreen" class="screen">
<div class="card">
<h2>Game Over</h2>
<div class="big-time"><span id="finalTime">0.0</span><small>s</small></div>
<span id="bestBadge" class="badge">✨ New personal best</span>
<p id="rankLine"></p>
<div class="board">
<div class="board-head"><span>🏆 Top survivors</span><span id="overTotal"></span></div>
<ol id="overBoard"></ol>
</div>
<div class="btn-row">
<button id="menuBtn" class="btn ghost">Menu</button>
<button id="retryBtn" class="btn">Play Again</button>
</div>
</div>
</div>
</div>
<script>
(function () {
var W = 340, H = 520;
var PLAYER_R = 11, MISSILE_R = 6;
var START_INTERVAL = 750, MIN_INTERVAL = 90, INTERVAL_DECAY_PER_SEC = 25;
var START_SPEED = 120, MAX_SPEED = 520, SPEED_GROWTH_PER_SEC = 14;
var START_HOMING = 0.4, MAX_HOMING = 2.2, HOMING_GROWTH_PER_SEC = 0.05;
var MAX_SPAWN_PER_WAVE = 4;
var canvas = document.getElementById('canvas');
var ctx = canvas.getContext('2d');
var stage = document.getElementById('stage');
var nameInput = document.getElementById('nameInput');
function fitCanvas() {
var scale = Math.max(0.1, Math.min(stage.clientWidth / W, stage.clientHeight / H));
canvas.style.width = Math.floor(W * scale) + 'px';
canvas.style.height = Math.floor(H * scale) + 'px';
}
window.addEventListener('resize', fitCanvas);
var player = { x: W / 2, y: H - 60, r: PLAYER_R };
var missiles = [];
var elapsed = 0;
var spawnTimer = 0;
var spawnInterval = START_INTERVAL;
var missileSpeed = START_SPEED;
var homingRate = START_HOMING;
var playerName = '';
var personalBest = 0;
var running = false;
var lastTime = null;
// Remember the last name used on this device (storage may be unavailable).
try { nameInput.value = localStorage.getItem('md-name') || ''; } catch (e) {}
function clampPlayer() {
player.x = Math.max(PLAYER_R, Math.min(W - PLAYER_R, player.x));
player.y = Math.max(PLAYER_R, Math.min(H - PLAYER_R, player.y));
}
function pointerPos(evt) {
var rect = canvas.getBoundingClientRect();
var t = evt.touches ? evt.touches[0] : evt;
var scaleX = canvas.width / rect.width;
var scaleY = canvas.height / rect.height;
return { x: (t.clientX - rect.left) * scaleX, y: (t.clientY - rect.top) * scaleY };
}
function movePlayerTo(evt) {
var p = pointerPos(evt);
player.x = p.x; player.y = p.y;
clampPlayer();
}
canvas.addEventListener('mousedown', movePlayerTo);
canvas.addEventListener('mousemove', function (e) { if (running) movePlayerTo(e); });
canvas.addEventListener('touchstart', function (e) { movePlayerTo(e); }, { passive: true });
canvas.addEventListener('touchmove', function (e) { e.preventDefault(); movePlayerTo(e); }, { passive: false });
function spawnMissile() {
var edge = Math.floor(Math.random() * 4);
var x, y;
if (edge === 0) { x = Math.random() * W; y = -20; }
else if (edge === 1) { x = W + 20; y = Math.random() * H; }
else if (edge === 2) { x = Math.random() * W; y = H + 20; }
else { x = -20; y = Math.random() * H; }
var angle = Math.atan2(player.y - y, player.x - x) + (Math.random() - 0.5) * 0.7;
missiles.push({
x: x, y: y, r: MISSILE_R,
vx: Math.cos(angle) * missileSpeed,
vy: Math.sin(angle) * missileSpeed,
trail: []
});
}
function updateHud() {
document.getElementById('timeLabel').textContent = (elapsed / 1000).toFixed(1);
}
function showScreen(id) {
['startScreen', 'gameScreen', 'overScreen'].forEach(function (s) {
document.getElementById(s).classList.toggle('show', s === id);
});
}
/* ---------- Ranking ---------- */
function renderBoard(listId, totalId, data) {
var top = (data && data.top) || [];
var medals = ['🥇', '🥈', '🥉'];
var html = '';
top.forEach(function (row, i) {
var me = data.me && row.name === data.me;
html += '<li' + (me ? ' class="me"' : '') + '>' +
'<span class="rk">' + (medals[i] || (i + 1)) + '</span>' +
'<span class="nm">' + escapeHtml(row.name) + (me ? ' (you)' : '') + '</span>' +
'<span class="tm">' + Number(row.time).toFixed(1) + 's</span></li>';
});
if (!top.length) html = '<li class="msg">No records yet. Be the first!</li>';
document.getElementById(listId).innerHTML = html;
document.getElementById(totalId).textContent = data && data.total ? data.total + ' players' : '';
}
function boardMessage(listId, msg, isError) {
document.getElementById(listId).innerHTML =
'<li class="msg' + (isError ? ' err' : '') + '">' + escapeHtml(msg) + '</li>';
}
function loadStartRanking() {
boardMessage('startBoard', 'Loading ranking…');
google.script.run
.withSuccessHandler(function (data) { renderBoard('startBoard', 'startTotal', data); })
.withFailureHandler(function (err) { boardMessage('startBoard', 'Could not load ranking: ' + err.message, true); })
.getRanking();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
/* ---------- Game flow ---------- */
function startGame() {
playerName = nameInput.value.trim().slice(0, 10) || 'Anonymous';
try { localStorage.setItem('md-name', playerName); } catch (e) {}
missiles = []; elapsed = 0; spawnTimer = START_INTERVAL;
missileSpeed = START_SPEED; homingRate = START_HOMING;
player.x = W / 2; player.y = H - 60;
document.getElementById('bestLabel').textContent = personalBest ? personalBest.toFixed(1) : '–';
updateHud();
showScreen('gameScreen');
fitCanvas();
draw();
running = true; lastTime = null;
requestAnimationFrame(loop);
}
function endGame() {
running = false;
var seconds = Math.round(elapsed / 100) / 10;
var isBest = seconds > personalBest;
if (isBest) personalBest = seconds;
document.getElementById('finalTime').textContent = seconds.toFixed(1);
document.getElementById('bestBadge').classList.toggle('show', isBest && seconds > 0);
document.getElementById('rankLine').textContent = 'Saving your record…';
document.getElementById('overTotal').textContent = '';
boardMessage('overBoard', 'Loading ranking…');
showScreen('overScreen');
// Save first, then show the ranking that the save returns — so your run is always included.
google.script.run
.withSuccessHandler(function (data) {
renderBoard('overBoard', 'overTotal', data);
if (data && data.myBest) personalBest = Math.max(personalBest, data.myBest);
document.getElementById('rankLine').innerHTML = data && data.myRank
? escapeHtml(data.me) + ' is ranked <strong>#' + data.myRank + '</strong> of ' + data.total +
' · best ' + Number(data.myBest).toFixed(1) + 's'
: '';
})
.withFailureHandler(function (err) {
document.getElementById('rankLine').textContent = '';
boardMessage('overBoard', 'Could not save your record: ' + err.message, true);
})
.saveTime(playerName, seconds);
}
function step(dt) {
elapsed += dt;
var elapsedSec = elapsed / 1000;
var dtSec = dt / 1000;
spawnInterval = Math.max(MIN_INTERVAL, START_INTERVAL - elapsedSec * INTERVAL_DECAY_PER_SEC);
missileSpeed = Math.min(MAX_SPEED, START_SPEED + elapsedSec * SPEED_GROWTH_PER_SEC);
homingRate = Math.min(MAX_HOMING, START_HOMING + elapsedSec * HOMING_GROWTH_PER_SEC);
spawnTimer -= dt;
if (spawnTimer <= 0) {
var count = Math.min(MAX_SPAWN_PER_WAVE, 1 + Math.floor(elapsedSec / 10));
for (var c = 0; c < count; c++) spawnMissile();
spawnTimer = spawnInterval;
}
for (var i = missiles.length - 1; i >= 0; i--) {
var m = missiles[i];
var desired = Math.atan2(player.y - m.y, player.x - m.x);
var cur = Math.atan2(m.vy, m.vx);
var diff = Math.atan2(Math.sin(desired - cur), Math.cos(desired - cur));
var maxTurn = homingRate * dtSec;
var turn = Math.max(-maxTurn, Math.min(maxTurn, diff));
var newAngle = cur + turn;
var spd = Math.hypot(m.vx, m.vy);
m.vx = Math.cos(newAngle) * spd;
m.vy = Math.sin(newAngle) * spd;
m.trail.push({ x: m.x, y: m.y });
if (m.trail.length > 8) m.trail.shift();
m.x += m.vx * dtSec;
m.y += m.vy * dtSec;
if (m.x < -40 || m.x > W + 40 || m.y < -40 || m.y > H + 40) {
missiles.splice(i, 1);
continue;
}
var dx = m.x - player.x, dy = m.y - player.y;
if (Math.hypot(dx, dy) < m.r + player.r) {
draw();
endGame();
return;
}
}
updateHud();
}
/* ---------- Drawing ---------- */
function drawGrid() {
ctx.strokeStyle = '#EDF1F7';
ctx.lineWidth = 1;
ctx.beginPath();
for (var x = 20; x < W; x += 20) { ctx.moveTo(x + 0.5, 0); ctx.lineTo(x + 0.5, H); }
for (var y = 20; y < H; y += 20) { ctx.moveTo(0, y + 0.5); ctx.lineTo(W, y + 0.5); }
ctx.stroke();
}
function drawPlayer() {
ctx.beginPath();
ctx.arc(player.x, player.y, player.r + 7, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(26, 95, 208, 0.12)';
ctx.fill();
ctx.beginPath();
ctx.arc(player.x, player.y, player.r, 0, Math.PI * 2);
ctx.fillStyle = '#1A5FD0';
ctx.fill();
ctx.lineWidth = 3;
ctx.strokeStyle = '#FFFFFF';
ctx.stroke();
}
function drawMissile(m) {
for (var t = 0; t < m.trail.length; t++) {
var p = m.trail[t];
ctx.beginPath();
ctx.arc(p.x, p.y, 1 + t * 0.3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(217, 48, 37, ' + (0.04 + t * 0.03) + ')';
ctx.fill();
}
var angle = Math.atan2(m.vy, m.vx);
ctx.save();
ctx.translate(m.x, m.y);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(m.r * 1.7, 0);
ctx.lineTo(-m.r, m.r * 0.8);
ctx.lineTo(-m.r * 0.4, 0);
ctx.lineTo(-m.r, -m.r * 0.8);
ctx.closePath();
ctx.fillStyle = '#D93025';
ctx.fill();
ctx.restore();
}
function draw() {
ctx.clearRect(0, 0, W, H);
drawGrid();
missiles.forEach(drawMissile);
drawPlayer();
}
function loop(ts) {
if (!running) return;
if (lastTime === null) lastTime = ts;
var dt = Math.min(33, ts - lastTime);
lastTime = ts;
step(dt);
if (running) {
draw();
requestAnimationFrame(loop);
}
}
document.getElementById('startBtn').addEventListener('click', startGame);
nameInput.addEventListener('keydown', function (e) { if (e.key === 'Enter') startGame(); });
document.getElementById('retryBtn').addEventListener('click', startGame);
document.getElementById('menuBtn').addEventListener('click', function () {
showScreen('startScreen');
loadStartRanking();
});
loadStartRanking();
})();
</script>
</body>
</html>
코드를 고친 뒤에는 배포 관리 → 수정 → 새 버전으로 다시 배포합니다.
나만 접근 (일반 액세스: 제한됨)
웹앱 주소로만 접속 (시트 권한 불필요)
모든 사용자
파트 4
게임이든, 수업 도구든 무엇이든 좋아요!
레드 팀과 블루 팀이 휴대폰으로 함께 두는 실시간 오목
Apps Script + 캐시 · 1.5초마다 판을 맞춰요
▶ 바로 해 보기관리자 화면에서 교실을 골라 보내면 그 교실 PC에 바로 뜨는 호출
Apps Script + 교실 PC 프로그램(Receiver.exe)
▶ 바로 해 보기둘 다 오늘 만든 것과 같은 Code.gs + index.html 구조예요. 아이디어만 있으면 AI와 함께 이렇게 키울 수 있어요.
예시 · 교실 호출은 GAS 웹앱과 교실 PC의 윈도우 프로그램(Receiver.exe)을 이어서 만들었어요.
GAS 웹앱 주소는 사람에게는 화면을, 프로그램에게는 데이터를 줘요주소 뒤에 ?mode=poll 을 붙이면 화면 대신 글자(JSON)로 답해요
GAS는 교실 PC에 먼저 연락할 수 없어요그래서 프로그램이 5초마다 물어봐요. 팀 오목도 같은 원리예요
// ① 프로그램이 웹앱 주소로 물어봐요 GET …/exec?mode=poll&name=컴퓨터실 // ② GAS(doGet)가 글자(JSON)로 답해요 { "messages": [ { "text": "홍길동 지금 교무실로 와라", "from": "교무부" } ] } // ③ 프로그램이 맨 앞에 빨간 창 + 음성
주소로 요청하고 답을 읽을 수 있으면 돼요C#, 파이썬, 엑셀 매크로, Wi-Fi 아두이노
교실 알림판, 급식실 대기 전광판, 과학실 센서 기록
Gemini에게 "GAS 웹앱과 통신하는 프로그램"이라고 구체적으로 부탁해요
Google Apps Script로 웹앱을 만들고 싶어요. 아이디어: [만들고 싶은 앱을 설명해 주세요] 사용자: [학생 / 교사] 스프레드시트에 데이터를 저장하나요?: [예 / 아니요] Code.gs와 index.html 두 개의 파일로 만들어 주세요. 휴대폰에서도 잘 보이게 하고, 화면 글자는 한국어로 해 주세요. 설명 없이 각 파일의 코드를 따로 나눠서 주세요.
오류가 나면 오류 메시지를 그대로 복사해서 Gemini에게 물어보세요.
Gemini에게 "뽑기 버튼을 초록색으로 바꿔 줘"라고 부탁해서 받은 코드예요. 직접 눌러 보고 원본과 비교해 보세요.
button background: #1A5FD0 → #188038버튼이 초록색이 됐어요 ✓
h1 color: #1A5FD0 → #188038제목 색도 바뀌었어요. 보기엔 괜찮지만 부탁하지 않은 변경이에요
split('\n') → split(',')줄마다 적은 명단을 쉼표로 나눠서, 늘 명단 전체가 한꺼번에 뽑혀요. 오류 메시지는 없어요
찾는 순서 · 화면에서 이상한 곳 → 그 일을 맡은 영역(HTML·CSS·JS) → 원본과 줄 비교. AI가 고쳐 줘도, 확인은 사람 몫이에요.
요청한 기능이 실제로 됐나요?
잘 되던 버튼·입력·저장이 그대로인가요?
시트 이름·저장 위치가 그대로인가요?SHEET_NAME이 바뀌면 기록이 빈 것처럼 보여요
아래 코드에서 [바꾸고 싶은 것]만 바꿔 주세요. 나머지는 그대로 두고, 바꾼 줄이 어디인지 알려 주세요. [지금 코드 붙여넣기]
고치기 전에 지금 코드를 메모장에 복사해 두고, 배포 → 테스트 배포 주소로 먼저 확인해요.
크롬에 구글 계정이 여러 개 로그인되어 있으면 생겨요시크릿 창(Ctrl + Shift + N)에서 주소를 열어 보세요
학교(Workspace) 계정은 학교 밖 공유가 막혀 있을 수 있어요'도메인 내 모든 사용자'를 고르거나 개인 Gmail 계정으로 만들어요
저장만 하면 웹앱에 반영되지 않아요배포 → 배포 관리 → 연필(수정) → 버전: 새 버전 → 배포
HTML 파일 이름이 정확히 index인지 확인해요Index, index.html.html, 띄어쓰기 모두 오류가 나요
액세스가 '모든 사용자'여야 다른 선생님들도 열어 볼 수 있어요.
우리 교실에 가장 필요한 앱은
선생님의 경험과 아이디어에서 시작됩니다.
코드는 AI가,아이디어는 선생님이!
이제 선생님의 교육 경험과 아이디어를 바탕으로, 우리 반에 꼭 필요한 앱을 직접 만들어 보세요!
script.google.com