← Lista · 목록

Google
Apps Script

Crea tu propia aplicación web con Apps ScriptApps Script로 만드는 나만의 웹앱

En la era de la IA, el docente crea sus propias appsAI 시대, 교사가 필요한 앱을 직접 만들어 쓰는 시대

Antes예전

Buscar una app parecida o esperar a que alguien la hiciera비슷한 앱을 찾거나, 누군가 만들어 주기를 기다림

Ahora지금

Con la IA, tú mismo creas la app que tu clase necesitaAI와 함께 우리 반에 딱 맞는 앱을 직접 만듦

La clave핵심

No hace falta ser programador: basta una buena idea프로그래머가 아니어도 좋은 아이디어만 있으면 충분

Lo que haremos hoy오늘 함께 할 일

  1. Ruleta · tu primera app web돌림판 · 나의 첫 웹앱
  2. Tablero de mensajes · guardar datos en una hoja de cálculo게시판 · 스프레드시트에 데이터 저장하기
  3. Esquiva los misiles · un juego con ranking미사일 피하기 · 랭킹이 있는 게임
  4. Tu propia app · crea tu idea y compártela나만의 웹앱 · 아이디어를 만들고 함께 공유하기

En cada práctica solo copias y pegas el código: ¡nadie se queda atrás!실습마다 코드를 복사해서 붙여넣기만 하면 돼요. 누구나 따라 할 수 있어요!

Parte 1 · 파트 1

Introducción a Apps Script y la ruletaApps Script 소개와 돌림판 만들기

▶ Ver la ruleta terminada · 완성된 돌림판 미리 보기

¿Qué es Google Apps Script?Google Apps Script란?

Es una plataforma de desarrollo de Google para usar y ampliar sus servicios con JavaScript.구글 서비스를 자바스크립트로 다루고 확장할 수 있도록 구글이 제공하는 개발 플랫폼입니다.

  • Hojas de cálculo · 스프레드시트
  • Documentos · 문서
  • Gmail
  • Calendar · 캘린더
  • Drive · 드라이브
  • Formularios · 폼

Características특징

JavaScript

Se escribe en un lenguaje conocido익숙한 언어로 작성

Servidores de Google구글 서버

No necesita correr en tu computadora내 컴퓨터에서 안 돌려도 됨

Integración con Google구글 연동

Hojas · Gmail · Calendar · Formularios시트 · 메일 · 캘린더 · 폼

Ventajas장점

Gratis무료

Solo necesitas una cuenta de Google구글 계정만 있으면 OK

Sin instalación설치 없음

Se programa directamente en el navegador브라우저에서 바로 개발

Automatización자동화

Hace por ti las tareas repetitivas반복 작업을 알아서 처리

Compartir공유

Publica tu app con un solo enlace링크 하나로 웹앱 배포

Empieza aquí여기서 시작하세요

script.google.com

Inicia sesión en Google → Nuevo proyecto구글 로그인 → 새 프로젝트

Puedes crear estos archivos이런 파일을 만들 수 있어요

Archivo JavaScript자바스크립트 파일

Código que hace funcionar la app (.gs)기능을 만드는 코드 (.gs)

Archivo HTMLHTML 파일

Página web que muestra la pantalla화면을 만드는 웹페이지

Práctica 1: Ruleta실습 1 : 돌림판 만들기

Code.gs

Abre la página페이지를 여는 역할

index.html

Crea la pantalla화면을 만드는 역할

Paso 1 · Code.gs1단계 · Code.gs (돌림판)

  1. Borra todo lo que hay en Code.gs y pega este códigoCode.gs 안의 내용을 모두 지우고 이 코드를 붙여넣기
  2. Guarda con Ctrl + SCtrl + S로 저장
Code.gs · 7 líneas · 7줄
// 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');
}

Paso 2 · index.html2단계 · index.html (돌림판)

  1. Botón + junto a Archivos → HTML → nombre: index파일 옆 + 버튼 → HTML → 이름: index
  2. Borra todo, pega este código y guarda (Ctrl + S)안을 모두 지우고 이 코드를 붙여넣은 뒤 저장
index.html · 183 líneas · 183줄
<!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>

Publicar como app web웹앱으로 배포하기

  1. Implementar → Nueva implementación → Aplicación web배포 → 새 배포 → 웹 앱
  2. Ejecutar como: Yo · Quién tiene acceso: Cualquier usuario실행 사용자: 나 · 액세스: 모든 사용자
  3. Abre la URL y prueba la ruleta주소(URL)를 열어 돌림판 확인

Parte 2 · 파트 2

Tablero de mensajes con Hojas de cálculo스프레드시트 연동 게시판 만들기

▶ Ver el tablero terminado · 완성된 게시판 미리 보기

Práctica 2: Tablero de mensajes실습 2 : 게시판 만들기

Escribir글쓰기

Cualquier persona누구나

Leer글 읽기

Solo el docente선생님만

Comentar · Borrar댓글 · 삭제

Solo el docente선생님만

¿Dónde guardamos las publicaciones?게시글은 어디에 저장할까요?

Necesidad필요

Una base de datos para guardar los mensajes게시글을 저장할 데이터베이스

Problema문제

Montar un servidor exige tiempo y conocimientos서버 구축과 설정에 시간과 지식이 필요

Solución해결

Usar Hojas de cálculo de Google como base de datos구글 스프레드시트를 데이터베이스로 활용

La hoja de cálculo funciona como base de datos스프레드시트가 간단한 데이터베이스 역할을 해요

No.NameTitleContentComments
1AnaHola¡Mi primer mensaje!¡Bienvenida!
2LuisPregunta¿Cuál es la tarea?

Cada fila es un mensaje; al publicar se añade una fila nueva한 줄이 글 하나, 사용자가 글을 남기면 새 줄이 생겨요

Dos archivos con funciones distintas파일은 두 개, 역할이 달라요

index.htmlPantalla
Donde se escribe화면 · 글 쓰는 곳
Code.gsProceso
Guardar · verificar · borrar처리 · 저장 · 확인 · 삭제
Hoja de cálculoAlmacén
Donde se acumulan스프레드시트 · 글이 쌓이는 곳

Code.gs es un archivo escrito en JavaScript.Code.gs는 자바스크립트로 쓴 파일이에요.

Empezar: crear la hoja de cálculo시작하기 : 스프레드시트 만들기

  1. Crea una nueva Hoja de cálculo de Google구글 스프레드시트를 새로 만들기
  2. En el menú: Extensiones → Apps Script상단 메뉴에서 확장 프로그램 → Apps Script
  3. Cuando se abra Code.gs, ¡todo listo!Code.gs 파일이 열리면 준비 완료

Al abrirlo así, queda conectado automáticamente a la hoja.이렇게 열면 스프레드시트와 자동으로 연결됩니다.

Paso 1 · Code.gs1단계 · Code.gs (게시판)

  1. Borra todo lo que hay en Code.gs y pega este códigoCode.gs 안의 내용을 모두 지우고 이 코드를 붙여넣기
  2. Cambia la contraseña de la línea 6 (TEACHER_PASSWORD)6번째 줄 비밀번호(TEACHER_PASSWORD)를 내 것으로 바꾸기
  3. Guarda con Ctrl + SCtrl + S로 저장
Code.gs · 166 líneas · 166줄
// 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 = 'America/Bogota';

// ---------------------------------------------------------------
// 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;
}

Paso 2 · index.html2단계 · index.html (게시판)

  1. Botón + junto a Archivos → HTML → nombre: index파일 옆 + 버튼 → HTML → 이름: index
  2. Borra todo, pega este código y guarda (Ctrl + S)안을 모두 지우고 이 코드를 붙여넣은 뒤 저장
index.html · 286 líneas · 286줄
<!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>

Publicar배포하기

  1. Implementar → Nueva implementación → Aplicación web (igual que en la Práctica 1)배포 → 새 배포 → 웹 앱 (설정은 실습 1과 동일)
  2. Autoriza el acceso a la hoja de cálculo스프레드시트 접근 권한 승인
  3. Comparte la URL con tus estudiantes주소(URL)를 학생에게 공유

Si cambias el código: Administrar implementaciones → Editar → Nueva versión.코드를 고친 뒤에는 배포 관리 → 수정 → 새 버전으로 다시 배포합니다.

Revisa los permisos공유 설정 확인

Hoja de cálculo스프레드시트

Solo tú (Acceso general: Restringido)나만 접근 (일반 액세스: 제한됨)

Estudiantes학생

Entran solo por la URL de la app (sin permiso en la hoja)웹앱 주소로만 접속 (시트 권한 불필요)

Acceso a la app웹앱 액세스

Cualquier usuario모든 사용자

Parte 3 · 파트 3

Juego: esquiva los misiles미사일 피하기 게임 만들기

▶ Ver el juego terminado · 완성된 게임 미리 보기

Práctica 3: Esquiva los misiles실습 3 : 미사일 피하기 게임

Jugar게임 플레이

Esquiva los misiles el mayor tiempo posible사방에서 날아오는 미사일을 최대한 오래 피하기

Guardar récord기록 저장

El nombre y el tiempo de supervivencia se guardan en la hoja이름과 생존 시간을 스프레드시트에 기록

Ranking랭킹 보기

Ordenado por quién sobrevivió más tiempo가장 오래 살아남은 순서로 확인

¿Dónde guardamos los récords?기록은 어디에 저장할까요?

Necesidad필요

Un lugar para registrar cuánto aguantó cada quien누가 얼마나 오래 버텼는지 기록할 곳

Problema문제

Anotar los récords a mano es tedioso기록을 매번 손으로 적어 두기 번거로움

Solución해결

Usar la hoja de cálculo como tabla de clasificación구글 스프레드시트를 랭킹판으로 활용

La hoja se convierte en el ranking스프레드시트가 랭킹판이 돼요

NameTime (s)Date
Ana42.32026-09-29 10:12
Luis37.12026-09-29 10:15

Cada vez que termina una partida se añade una fila nueva게임이 끝날 때마다 새 줄이 자동으로 추가돼요

Dos archivos con funciones distintas파일은 두 개, 역할이 달라요

index.htmlPantalla
Donde se juega화면 · 게임 플레이
Code.gsProceso
Guardar récord · calcular ranking처리 · 기록 저장 · 랭킹 계산
Hoja de cálculoAlmacén
Donde se acumulan los récords스프레드시트 · 기록이 쌓이는 곳

Code.gs es un archivo escrito en JavaScript.Code.gs는 자바스크립트로 쓴 파일이에요.

Empezar: crear la hoja de cálculo시작하기 : 스프레드시트 만들기

  1. Crea una nueva Hoja de cálculo de Google구글 스프레드시트를 새로 만들기
  2. En el menú: Extensiones → Apps Script상단 메뉴에서 확장 프로그램 → Apps Script
  3. Cuando se abra Code.gs, ¡todo listo!Code.gs 파일이 열리면 준비 완료

La hoja Records se crea sola en la primera partida.첫 게임을 하면 Records 시트가 자동으로 만들어져요.

Paso 1 · Code.gs1단계 · Code.gs (게임)

  1. Borra todo lo que hay en Code.gs y pega este códigoCode.gs 안의 내용을 모두 지우고 이 코드를 붙여넣기
  2. Guarda con Ctrl + SCtrl + S로 저장
Code.gs · 93 líneas · 93줄
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
  };
}

Paso 2 · index.html2단계 · index.html (게임)

  1. Botón + junto a Archivos → HTML → nombre: index파일 옆 + 버튼 → HTML → 이름: index
  2. Borra todo, pega este código y guarda (Ctrl + S)안을 모두 지우고 이 코드를 붙여넣은 뒤 저장
index.html · 398 líneas · 398줄
<!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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>

Publicar배포하기

  1. Implementar → Nueva implementación → Aplicación web (igual que en la Práctica 1)배포 → 새 배포 → 웹 앱 (설정은 실습 1과 동일)
  2. Autoriza el acceso a la hoja de cálculo스프레드시트 접근 권한 승인
  3. Comparte la URL con tus estudiantes주소(URL)를 학생에게 공유

Si cambias el código: Administrar implementaciones → Editar → Nueva versión.코드를 고친 뒤에는 배포 관리 → 수정 → 새 버전으로 다시 배포합니다.

Revisa los permisos공유 설정 확인

Hoja de cálculo스프레드시트

Solo tú (Acceso general: Restringido)나만 접근 (일반 액세스: 제한됨)

Estudiantes학생

Entran solo por la URL de la app (sin permiso en la hoja)웹앱 주소로만 접속 (시트 권한 불필요)

Acceso a la app웹앱 액세스

Cualquier usuario모든 사용자

Parte 4 · 파트 4

Desafío: crea tu propia app도전 : 나만의 웹앱 만들기

Convierte tu idea en una app web선생님의 아이디어를 웹앱으로 만들어 보세요

Un juego, una herramienta para la clase… ¡cualquier idea vale!게임이든, 수업 도구든 무엇이든 좋아요!

  • Juego de repaso · 복습 게임
  • Cuestionario · 퀴즈
  • Temporizador · 타이머
  • Formar equipos · 모둠 뽑기
  • Encuesta de clase · 학급 설문
  • Registro de lectura · 독서 기록장

Pide ayuda a GeminiGemini에게 도움 요청하기

  1. Copia el texto y completa los [ ]문장을 복사해서 [ ] 부분을 채우기
  2. Pega el código en Code.gs e index.html실습 때처럼 Code.gs와 index.html에 붙여넣기
  3. Implementar → Aplicación web배포 → 웹 앱
Gemini · plantilla · 질문 틀
Quiero crear una aplicación web con Google Apps Script.
Idea: [describe aquí tu app]
Quién la usa: [estudiantes / docentes]
¿Guarda datos en una hoja de cálculo?: [sí / no]
Hazla con 2 archivos: Code.gs e index.html.
Que se vea bien en el celular y que los textos estén en español.
Dame el código de cada archivo en un bloque separado, sin explicaciones.

Si algo falla, copia el mensaje de error y pregúntale a Gemini.오류가 나면 오류 메시지를 그대로 복사해서 Gemini에게 물어보세요.

Comparte tu app en el tablero게시판에 공유해 주세요

  1. Copia la URL de tu app web publicada배포한 웹앱 주소(URL) 복사하기
  2. En el tablero del curso escribe: Nombre · Título (nombre de tu app) · Contenido (URL + una frase)연수 게시판에 이름 · 제목(앱 이름) · 내용(주소 + 한 줄 소개) 올리기
  3. Al final, ¡las abrimos y probamos juntos!마지막에 함께 열어 보며 공유하는 시간을 가져요

Revisa que el acceso sea Cualquier usuario; si no, los demás no podrán abrirla.액세스가 '모든 사용자'여야 다른 선생님들도 열어 볼 수 있어요.

¡Gracias!감사합니다

La mejor app para tu clase nace de tu propia experiencia. La IA escribe el código; tú pones la idea.우리 교실에 가장 좋은 앱은 선생님의 경험에서 나옵니다. 코드는 AI가, 아이디어는 선생님이.

Hoy creaste tres apps web. Mañana, crea la que tu clase necesita.오늘 웹앱 세 개를 만들었어요. 이제 우리 반에 필요한 앱을 직접 만들어 보세요.

script.google.com