本文へスキップ
NextCode.ltd

1962年〜 ・ ゲーム

線の宇宙ゲーム(重力のある星のまわりで撃ち合う)

中央の星に引っぱられながら、慣性で飛ぶ2機が撃ち合う。1962年の大学のコンピュータから始まった遊び方を、オリジナルの機体で再現した遊べるデモ。

線の宇宙ゲーム(重力のある星のまわりで撃ち合う)のイメージイラスト(当時の雰囲気を描いたオリジナル画)
イメージイラスト(AIで描いたオリジナル。実在の機器・ロゴを写したものではありません)

01歴史と背景

1961年、MIT の学生たちが PDP-1 というコンピュータで、2機の宇宙船が撃ち合うゲーム Spacewar! を考え、翌1962年の春に大きく改良しました。中央の太陽が重力で船を引き寄せ、近づきすぎると燃え尽きます。追い詰められたときの緊急ワープもありましたが、出た先が敵の目の前だったりもしたそうです。

1971年には、これを1人用に簡単にしたゲームが業務用として量産されました。こちらは普通のテレビと同じ方式の画面です。1977年には、線で描くベクター画面の2人対戦のアーケードゲームが登場します。設計者は、MIT を見学したときに見た Spacewar! を元にしたと語っています。

1979年11月には、撃つと小さく割れていく岩をよける宇宙ゲームが出て、大ヒットしました。

このページのデモは「重力のある星・慣性で飛ぶ・画面の端がつながる・緊急ワープ」というジャンルの仕組みだけを借りたオリジナルです。自機(割れた尾の矢じり)と敵(棘の付いた輪)の形、星の絵、名前は自分で作りました。

02見た目の素材

色(押すとコードをコピー)

線(緑)

デモの色

#39FF6E

背景

#000000

仕様(見た目に効く数字)

重力中央の星から距離の2乗に反比例して引っぱる(デモの値は遊びやすく調整)
動き慣性で飛ぶ。噴射しないと止まらない
画面の端上下・左右がつながっている
デモの操作←→ 回る・↑ 噴射・↓ ワープ・スペース 撃つ(弾は6発まで・少しずつ補充)

今使える書体(無料)

  • VT323 ↗

    DEC の端末の文字をもとにした、細身のドット書体。英数字のみ。

音の特徴

デモの音はすべて Web Audio で作ったオリジナル。

03再現レシピ

「▶ 再生」で動きます。音は「音なし」を押して切り替えたときだけ鳴ります。下のコードは、いま動いているデモのファイルそのものです。

本物っぽくするコツ

  • 01重力は「星への向き × 強さ ÷ 距離の2乗」を毎コマ速度に足すだけ。距離が0に近づくと値が暴れるので、最小値を決めておく。
  • 02弾にも同じ重力をかけると、弾が星のまわりで曲がって飛び、狙い撃ちが楽しくなる。
  • 03敵の動きは「相手の少し先の位置を狙って向きを変える」「星に近すぎたら逃げる」の2つだけで、十分に手ごわくなる。
  • 04画面は OrthographicCamera(遠近のないカメラ)で真上から。縦横比が変わったら上下の範囲を計算し直す。
  • 05自機を三角形にすると、有名なゲームにそっくりになる。形は自分で決める。

コード

▶components/retro/demos/vector-space.tsx266行
"use client";

import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import RetroFrame, { type DemoState } from "../RetroFrame";
import GamePad from "../GamePad";
import { bindKeys, createVectorStage, glowLine, linesFrom, newKeys, type Keys } from "@/lib/retro/vectorScene";
import { noise, tone } from "@/lib/retro/audio";

/**
 * 中央の星に引っぱられながら撃ち合う、線の宇宙ゲーム(このサイトのオリジナル)。
 * 「重力のある星・慣性で飛ぶ・画面の端がつながる・緊急ワープ」は1960年代からあるジャンルの仕組みとして使い、
 * 自機(割れた尾の矢じり)・敵(棘の付いた輪)・星の絵・名前は自分で作ったもの。
 * 操作: ←→ 回る / ↑ 噴射 / ↓ ワープ(出る場所は運しだい)/ スペース 撃つ
 */
const GREEN = 0x39ff6e;
const W = 16; // 画面の横の半分(上下は縦横比で決まる)
const G = 9; // 星の重力の強さ

type Ship = { g: THREE.Group; p: THREE.Vector2; v: THREE.Vector2; a: number; alive: boolean; cool: number; respawn: number };
type Shot = { m: THREE.LineSegments; p: THREE.Vector2; v: THREE.Vector2; life: number; mine: boolean };

/** 自機: 矢じりの先と、2つに割れた尾、真ん中の背骨 */
function myShip(mat: THREE.Material) {
    const g = new THREE.Group();
    g.add(linesFrom([
        0.9, 0, 0, -0.5, 0.45, 0,
        0.9, 0, 0, -0.5, -0.45, 0,
        -0.5, 0.45, 0, -0.2, 0.12, 0,
        -0.5, -0.45, 0, -0.2, -0.12, 0,
        -0.2, 0.12, 0, -0.75, 0.2, 0,
        -0.2, -0.12, 0, -0.75, -0.2, 0,
        0.9, 0, 0, -0.35, 0, 0,
    ], mat));
    return g;
}

/** 敵: 輪に4本の棘 */
function rivalShip(mat: THREE.Material) {
    const g = new THREE.Group();
    const pts: number[] = [];
    for (let i = 0; i < 12; i++) {
        const a1 = (i / 12) * Math.PI * 2, a2 = ((i + 1) / 12) * Math.PI * 2;
        pts.push(Math.cos(a1) * 0.45, Math.sin(a1) * 0.45, 0, Math.cos(a2) * 0.45, Math.sin(a2) * 0.45, 0);
    }
    for (let i = 0; i < 4; i++) {
        const a = (i / 4) * Math.PI * 2 + Math.PI / 4;
        pts.push(Math.cos(a) * 0.45, Math.sin(a) * 0.45, 0, Math.cos(a) * 0.95, Math.sin(a) * 0.95, 0);
    }
    pts.push(0.45, 0, 0, 1.0, 0, 0); // 向きの目印
    g.add(linesFrom(pts, mat));
    return g;
}

/** 中央の星: 長さの違う光の筋が回る */
function star(mat: THREE.Material) {
    const pts: number[] = [];
    for (let i = 0; i < 10; i++) {
        const a = (i / 10) * Math.PI * 2, r = i % 2 ? 0.5 : 1.0;
        pts.push(0, 0, 0, Math.cos(a) * r, Math.sin(a) * r, 0);
    }
    return linesFrom(pts, mat);
}

function Screen({ run, skip, sound, keys }: DemoState & { keys: Keys }) {
    const boxRef = useRef<HTMLDivElement>(null);
    const [hud, setHud] = useState({ score: 0, lives: 3, torps: 6, msg: "▶ 再生で発進" });

    useEffect(() => {
        const box = boxRef.current;
        if (!box) return;
        const camera = new THREE.OrthographicCamera(-W, W, W * 0.75, -W * 0.75, -10, 10);
        const stage = createVectorStage(box, camera, { damp: 0.8, bloom: 0.9 });
        const { scene } = stage;
        const mat = glowLine(GREEN), dim = glowLine(GREEN, 0.5);
        let H = W * 0.75;

        // 遠くの星(点)
        const bg: number[] = [];
        for (let i = 0; i < 90; i++) {
            const x = (Math.random() * 2 - 1) * W, y = (Math.random() * 2 - 1) * W;
            bg.push(x, y, 0, x + 0.05, y, 0);
        }
        scene.add(linesFrom(bg, dim));
        const sun = star(mat);
        scene.add(sun);

        const makeShip = (g: THREE.Group, x: number, y: number, a: number): Ship => {
            scene.add(g);
            return { g, p: new THREE.Vector2(x, y), v: new THREE.Vector2(0, 0.9 * Math.sign(-x || 1)), a, alive: true, cool: 0, respawn: 0 };
        };
        const me = makeShip(myShip(mat), -9, 0, 0);
        const cpu = makeShip(rivalShip(mat), 9, 0, Math.PI);
        let score = 0, lives = 3, torps = 6, over = false;
        const playing = run > 0 && !skip;
        const shots: Shot[] = [];
        const sparks: { m: THREE.LineSegments; v: THREE.Vector2; life: number }[] = [];

        const wrap = (p: THREE.Vector2) => {
            if (p.x > W) p.x -= 2 * W; if (p.x < -W) p.x += 2 * W;
            if (p.y > H) p.y -= 2 * H; if (p.y < -H) p.y += 2 * H;
        };
        const gravity = (p: THREE.Vector2, v: THREE.Vector2, dt: number) => {
            const d2 = Math.max(0.8, p.lengthSq());
            v.addScaledVector(p.clone().normalize(), (-G / d2) * dt);
        };
        const fire = (s: Ship, mine: boolean) => {
            const dir = new THREE.Vector2(Math.cos(s.a), Math.sin(s.a));
            const m = linesFrom([0, 0, 0, dir.x * 0.5, dir.y * 0.5, 0], mat);
            scene.add(m);
            shots.push({ m, p: s.p.clone().addScaledVector(dir, 1.1), v: s.v.clone().addScaledVector(dir, 14), life: 1.6, mine });
            if (sound) tone(mine ? 1100 : 700, 0.08, "square", 0.15, 0, 400);
        };
        const explode = (s: Ship) => {
            s.alive = false;
            s.g.visible = false;
            s.respawn = 2;
            for (let i = 0; i < 14; i++) {
                const a = Math.random() * Math.PI * 2;
                const m = linesFrom([0, 0, 0, Math.cos(a) * 0.6, Math.sin(a) * 0.6, 0], mat);
                m.position.set(s.p.x, s.p.y, 0);
                scene.add(m);
                sparks.push({ m, v: new THREE.Vector2(Math.cos(a), Math.sin(a)).multiplyScalar(3 + Math.random() * 5).add(s.v), life: 1.2 });
            }
            if (sound) noise(0.6, 0.45, 0, 120);
        };
        const reset = (s: Ship, x: number) => {
            s.p.set(x, (Math.random() - 0.5) * H);
            s.v.set(0, 0.9 * Math.sign(-x));
            s.a = x < 0 ? 0 : Math.PI;
            s.alive = true;
            s.g.visible = true;
        };

        const unbind = bindKeys(keys, () => playing && !over && stage.visible());
        let warpCool = 0;
        let last = performance.now(), raf = 0, hudAt = 0, idleAt = 0;

        const step = (now: number) => {
            raf = requestAnimationFrame(step);
            // 再生前の待機画面と「止まった画面」は1秒8コマで十分(ページを重くしない)
            if ((run === 0 || skip) && now - idleAt < 125) return;
            idleAt = now;
            if (!stage.due(now)) return; // 1秒30コマまで・見えていないときは描かない
            const dt = Math.min(0.05, (now - last) / 1000); // 経過時間は「実際に描いたコマの間隔」で測る
            last = now;
            H = W / stage.aspect();
            camera.top = H; camera.bottom = -H; camera.updateProjectionMatrix();
            sun.rotation.z += dt * 1.5;
            sun.scale.setScalar(1 + Math.sin(now / 150) * 0.15);

            // 自分
            if (playing && !over && me.alive) {
                if (keys.left) me.a += 3.2 * dt;
                if (keys.right) me.a -= 3.2 * dt;
                if (keys.up) {
                    me.v.x += Math.cos(me.a) * 7 * dt; me.v.y += Math.sin(me.a) * 7 * dt;
                    if (sound && Math.random() < 0.3) noise(0.05, 0.08, 0, 2000);
                }
                warpCool -= dt;
                if (keys.down && warpCool <= 0) {
                    warpCool = 3;
                    me.p.set((Math.random() * 2 - 1) * W, (Math.random() * 2 - 1) * H); // 出る場所は運しだい
                    if (sound) tone(300, 0.3, "sine", 0.2, 0, 1500);
                }
                me.cool -= dt;
                if (keys.fire && me.cool <= 0 && torps > 0) { me.cool = 0.3; torps--; fire(me, true); }
            }
            if (torps < 6 && Math.random() < dt * 0.4) torps++; // 弾は少しずつ補充される

            // 敵: 自分の未来の位置を狙って向きを変え、星に近づきすぎたら逃げる
            if (cpu.alive && (playing || run === 0 || skip)) {
                const target = me.p.clone().addScaledVector(me.v, 0.6);
                const want = Math.atan2(target.y - cpu.p.y, target.x - cpu.p.x);
                let diff = want - cpu.a;
                diff = Math.atan2(Math.sin(diff), Math.cos(diff));
                cpu.a += Math.sign(diff) * Math.min(Math.abs(diff), 2.4 * dt);
                const danger = cpu.p.length() < 5;
                const thrustAng = danger ? Math.atan2(cpu.p.y, cpu.p.x) : cpu.a;
                if (danger || cpu.v.length() < 3) { cpu.v.x += Math.cos(thrustAng) * 5 * dt; cpu.v.y += Math.sin(thrustAng) * 5 * dt; }
                cpu.cool -= dt;
                if (playing && !over && me.alive && Math.abs(diff) < 0.15 && cpu.cool <= 0) { cpu.cool = 1.4; fire(cpu, false); }
            }

            for (const s of [me, cpu]) {
                if (!s.alive) {
                    s.respawn -= dt;
                    if (s.respawn <= 0 && (s === cpu || !over)) reset(s, s === me ? -9 : 9);
                    continue;
                }
                gravity(s.p, s.v, dt);
                s.v.clampLength(0, 12);
                s.p.addScaledVector(s.v, dt);
                wrap(s.p);
                s.g.position.set(s.p.x, s.p.y, 0);
                s.g.rotation.z = s === cpu ? cpu.a : s.a;
                if (s.p.length() < 0.9) { // 星に落ちた
                    explode(s);
                    if (s === me && playing) { lives--; if (lives <= 0) over = true; }
                    if (s === cpu && playing) score += 50;
                }
            }

            for (let i = shots.length - 1; i >= 0; i--) {
                const s = shots[i];
                gravity(s.p, s.v, dt);
                s.p.addScaledVector(s.v, dt);
                wrap(s.p);
                s.m.position.set(s.p.x, s.p.y, 0);
                s.m.rotation.z = Math.atan2(s.v.y, s.v.x);
                s.life -= dt;
                const victim = s.mine ? cpu : me;
                let hit = false;
                if (victim.alive && s.p.distanceTo(victim.p) < 0.8) {
                    hit = true;
                    explode(victim);
                    if (s.mine) score += 100;
                    else if (playing) { lives--; if (lives <= 0) over = true; }
                }
                if (hit || s.life <= 0 || s.p.length() < 0.9) { scene.remove(s.m); s.m.geometry.dispose(); shots.splice(i, 1); }
            }

            for (let i = sparks.length - 1; i >= 0; i--) {
                const s = sparks[i];
                s.m.position.x += s.v.x * dt; s.m.position.y += s.v.y * dt;
                s.m.rotation.z += dt * 6;
                s.life -= dt;
                if (s.life <= 0) { scene.remove(s.m); s.m.geometry.dispose(); sparks.splice(i, 1); }
            }

            stage.render();
            if (now - hudAt > 100) {
                hudAt = now;
                setHud({ score, lives, torps, msg: run === 0 ? "▶ 再生で発進" : skip ? "(止まった画面)" : over ? "GAME OVER ─ ↻ もう一度" : "" });
            }
        };
        raf = requestAnimationFrame(step);

        return () => {
            cancelAnimationFrame(raf);
            unbind();
            Object.assign(keys, newKeys());
            stage.dispose();
        };
    }, [run, skip, sound, keys]);

    return (
        <div style={{ position: "absolute", inset: 0, background: "#000" }}>
            <div ref={boxRef} style={{ position: "absolute", inset: 0 }} role="img" aria-label="中央の星のまわりで2機が撃ち合う、線だけの宇宙ゲームの画面" />
            <div className="term phos-green" style={{ ["--cols" as string]: 40, pointerEvents: "none" }}>
                {`SCORE ${String(hud.score).padStart(5, "0")}  LIVES ${hud.lives}  TORP ${"|".repeat(hud.torps)}`}
                {hud.msg && `\n\n\n\n        ${hud.msg}`}
            </div>
        </div>
    );
}

export default function VectorSpaceDemo() {
    const [keys] = useState<Keys>(newKeys);
    return (
        <RetroFrame label="線の宇宙ゲーム(オリジナル・遊べます)" bezel="#1b1d24" below={(s) => <GamePad keys={keys} disabled={s.run === 0 || s.skip} />}>
            {(s) => <Screen {...s} keys={keys} />}
        </RetroFrame>
    );
}

このデモが使っている共通部品(全デモ共通・React / Next.js 用)

▶components/retro/RetroFrame.tsx83行
"use client";

import { useEffect, useState, type ReactNode } from "react";
import { stopAll, unlockAudio } from "@/lib/retro/audio";

export type DemoState = {
    /** 再生ボタンを押した回数。0 のあいだは止まっている。増えるたびに最初からやり直す */
    run: number;
    /** true なら演出を飛ばして完成した画面を出す(SKIP・動きを減らす設定) */
    skip: boolean;
    /** 音を鳴らしてよいか(押して許可した後だけ true) */
    sound: boolean;
};

/**
 * デモの枠。再生・スキップ・音の切り替えを全デモで共通にする。
 * - 最初は止まっている(勝手に動かない・音も出ない)。autoplay のデモだけは開いた瞬間から動く(音は出ない)
 * - OS の「視差効果を減らす」が有効なら、最初から完成した画面を見せる
 * - ページを離れたら音を止める
 */
export default function RetroFrame({
    label,
    bezel = "#1b1b1b",
    children,
    below,
    autoplay = false,
}: {
    /** 枠の下に出す名前 例: "VT100風ログイン" */
    label: string;
    /** 画面のまわりの枠の色 */
    bezel?: string;
    children: (s: DemoState) => ReactNode;
    /** 画面の下に置く操作部品(十字ボタンなど)。画面の中に重ねると絵が隠れるので外に出す */
    below?: (s: DemoState) => ReactNode;
    /** true なら開いた瞬間から動かす(音は鳴らさない)。ずっと動いているのが見どころのデモ用 */
    autoplay?: boolean;
}) {
    const [run, setRun] = useState(autoplay ? 1 : 0);
    const [skip, setSkip] = useState(false);
    const [sound, setSound] = useState(false);

    useEffect(() => () => stopAll(), []);

    // 「視差効果を減らす」でも動きは止めない(浩樹さんの決定・設計図 10.7)。
    // 止めてよいのはちらつき・点滅・白い光・揺れだけで、デモの動き(打ち込み・起動の流れ)はそれに当たらない。
    const state: DemoState = { run, skip, sound };

    function play() {
        if (sound) unlockAudio();
        setSkip(false);
        setRun((r) => r + 1);
    }

    function toggleSound() {
        if (!sound) unlockAudio();
        else stopAll();
        setSound(!sound);
    }

    return (
        <figure className="retro-frame" data-playing={run > 0 ? "1" : "0"}>
            <div className="retro-bezel" style={{ background: bezel }}>
                <div className="retro-screen">{children(state)}</div>
            </div>
            {below?.(state)}
            <figcaption className="retro-controls">
                <span className="retro-label">{label}</span>
                <span className="retro-buttons">
                    <button type="button" onClick={play} className="retro-btn retro-btn-main">
                        {run === 0 ? "▶ 再生" : "↻ もう一度"}
                    </button>
                    <button type="button" onClick={() => { stopAll(); setSkip(true); setRun((r) => Math.max(r, 1)); }} className="retro-btn">
                        ⏭ 最後まで飛ばす
                    </button>
                    <button type="button" onClick={toggleSound} aria-pressed={sound} className="retro-btn">
                        {sound ? "🔊 音あり" : "🔇 音なし"}
                    </button>
                </span>
            </figcaption>
        </figure>
    );
}
▶lib/retro/audio.ts85行
// レトロ辞典のデモ用の音。音声ファイルは使わず Web Audio でその場で作る。
// 元は自作ゲーム「バベルのとう」(babel.nextcode.ltd) の lib/audio.ts。
// 🚨 ブラウザは「人が押す前」に音を出させない。必ずボタンを押した処理の中で unlockAudio() を呼ぶ。

type Wave = "square" | "triangle" | "sawtooth" | "sine";

let ctx: AudioContext | null = null;
let master: GainNode | null = null;

function ac(): AudioContext | null {
    if (typeof window === "undefined") return null;
    if (!ctx) {
        const AC = window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
        if (!AC) return null;
        ctx = new AC();
        master = ctx.createGain();
        master.gain.value = 0.18;
        master.connect(ctx.destination);
    }
    return ctx;
}

/** iOS Safari 対策。再生ボタンを押した処理の中で必ず呼ぶ */
export function unlockAudio(): void {
    const c = ac();
    if (c && c.state === "suspended") void c.resume();
}

/** 1音。freq(Hz)・長さ(秒)・波形・音量・何秒後に鳴らすか・終わりの周波数(音程を滑らせる) */
export function tone(freq: number, dur: number, wave: Wave = "square", vol = 0.5, delay = 0, slideTo?: number): void {
    const c = ac();
    if (!c || !master) return;
    const t0 = c.currentTime + delay;
    const osc = c.createOscillator();
    const g = c.createGain();
    osc.type = wave;
    osc.frequency.setValueAtTime(freq, t0);
    if (slideTo != null) osc.frequency.exponentialRampToValueAtTime(Math.max(1, slideTo), t0 + dur);
    g.gain.setValueAtTime(0.0001, t0);
    g.gain.exponentialRampToValueAtTime(vol, t0 + 0.005);
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
    osc.connect(g);
    g.connect(master);
    osc.start(t0);
    osc.stop(t0 + dur + 0.02);
}

/** ノイズ(ザーッ)。hp より低い音を切る。カチャカチャ音や通信の雑音に使う */
export function noise(dur: number, vol = 0.4, delay = 0, hp = 800): void {
    const c = ac();
    if (!c || !master) return;
    const t0 = c.currentTime + delay;
    const len = Math.max(1, Math.floor(c.sampleRate * dur));
    const buf = c.createBuffer(1, len, c.sampleRate);
    const data = buf.getChannelData(0);
    for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
    const src = c.createBufferSource();
    src.buffer = buf;
    const g = c.createGain();
    g.gain.setValueAtTime(vol, t0);
    g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
    const f = c.createBiquadFilter();
    f.type = "highpass";
    f.frequency.value = hp;
    src.connect(f);
    f.connect(g);
    g.connect(master);
    src.start(t0);
    src.stop(t0 + dur + 0.02);
}

/** 2つの周波数を同時に鳴らす(電話のプッシュ音=DTMF) */
export function dual(f1: number, f2: number, dur: number, vol = 0.25, delay = 0): void {
    tone(f1, dur, "sine", vol, delay);
    tone(f2, dur, "sine", vol, delay);
}

/** 鳴っている音をまとめて止める(スキップ・ページ移動のとき) */
export function stopAll(): void {
    if (!ctx) return;
    void ctx.close();
    ctx = null;
    master = null;
}
▶lib/retro/useScript.ts103行
"use client";

import { useEffect, useRef, useState } from "react";

/**
 * 文字を「当時の速さ」で1文字ずつ出すための台本。
 * 1行ずつ { text, cps, pause, onStart } を並べる。
 * - cps   … 1秒に出す文字数。110ボーのテレタイプは10、1200ボーのモデムは約120(ボー÷10)
 * - pause … その行を出し終わってから次の行まで待つミリ秒
 * - onStart … 行を出し始める瞬間に呼ぶ(音を鳴らす等)
 * 画面の更新は文字単位。60fpsのなめらかさを出さないのが「本物っぽさ」のコツ。
 */
export type ScriptLine = {
    text: string;
    cps?: number;
    pause?: number;
    /** true なら1文字ずつではなく、行ごと一度に出す(画面の切り替え・メモリ表示など) */
    instant?: boolean;
    onStart?: () => void;
};

export function useScript(
    lines: ScriptLine[],
    run: number,
    skip: boolean,
    defaultCps = 40,
    /** 1文字出すたびに呼ぶ(テレタイプの打鍵音など)。空白と改行では呼ばない */
    onChar?: (ch: string) => void,
) {
    const [out, setOut] = useState("");
    const [done, setDone] = useState(false);
    const linesRef = useRef(lines);
    linesRef.current = lines;
    const charRef = useRef(onChar);
    charRef.current = onChar;

    useEffect(() => {
        const all = linesRef.current;
        const full = all.map((l) => l.text).join("\n");
        if (run === 0) {
            setOut("");
            setDone(false);
            return;
        }
        if (skip) {
            setOut(full);
            setDone(true);
            return;
        }
        let cancelled = false;
        const timers: ReturnType<typeof setTimeout>[] = [];
        const wait = (ms: number) => new Promise<void>((r) => timers.push(setTimeout(r, ms)));

        (async () => {
            let shown = "";
            setOut("");
            setDone(false);
            for (let i = 0; i < all.length; i++) {
                if (cancelled) return;
                const line = all[i];
                line.onStart?.();
                const prefix = i === 0 ? "" : "\n";
                if (line.instant) {
                    shown += prefix + line.text;
                    setOut(shown);
                } else {
                    shown += prefix;
                    const step = 1000 / (line.cps ?? defaultCps);
                    for (const ch of line.text) {
                        if (cancelled) return;
                        shown += ch;
                        setOut(shown);
                        if (ch.trim()) charRef.current?.(ch);
                        await wait(step);
                    }
                }
                await wait(line.pause ?? 120);
            }
            if (!cancelled) setDone(true);
        })();

        return () => {
            cancelled = true;
            timers.forEach(clearTimeout);
        };
    }, [run, skip, defaultCps]);

    return { out, done };
}

/**
 * 端末と同じく、下まで来たら上へ送る(最新の行がいつも見える)。
 * 画面より行が多いデモで、はみ出した文字が切れないようにする。
 */
export function useAutoScroll<T extends HTMLElement>(dep: unknown) {
    const ref = useRef<T>(null);
    useEffect(() => {
        const el = ref.current;
        if (el) el.scrollTop = el.scrollHeight;
    }, [dep]);
    return ref;
}
▶lib/retro/vectorScene.ts155行
import * as THREE from "three";
import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { AfterimagePass } from "three/addons/postprocessing/AfterimagePass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

/**
 * ベクター画面の土台(Three.js)。線のゲームやデモで共通に使う。
 * - 残光: AfterimagePass(damp が 1 に近いほど尾が長い)
 * - にじみ: UnrealBloomPass
 * - 箱の大きさに合わせて描き直す・画面の外やタブが隠れているあいだは描かない
 * - 1秒30コマまで。描くのに時間がかかる端末では、解像度を段階的に下げ、それでも重ければにじみを切る
 *   (GPU の弱いスマホや、GPU なしで動くブラウザで、ページ全体が固まるのを防ぐ)
 */
export type VectorStage = {
    renderer: THREE.WebGLRenderer;
    scene: THREE.Scene;
    composer: EffectComposer;
    after: AfterimagePass;
    /** 描いてよいか(画面に見えていて、タブも表に出ている) */
    visible: () => boolean;
    /** このコマを描く番か(見えていて、前に描いてから 1/30 秒たった) */
    due: (now: number) => boolean;
    /** 1コマ描く。かかった時間を測り、重ければ自動で質を落とす */
    render: () => void;
    /** 箱の縦横比(幅÷高さ) */
    aspect: () => number;
    dispose: () => void;
};

export function createVectorStage(box: HTMLElement, camera: THREE.Camera, opts: { bg?: number; damp?: number; bloom?: number } = {}): VectorStage {
    const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
    // 質の段階: 解像度の倍率。重いと下の段へ落とす
    const LEVELS = [Math.min(window.devicePixelRatio, 1.5), 1, 0.75, 0.5];
    let level = 0;
    renderer.setPixelRatio(LEVELS[0]);
    renderer.setClearColor(opts.bg ?? 0x000000, 1);
    Object.assign(renderer.domElement.style, { width: "100%", height: "100%", display: "block" });
    box.appendChild(renderer.domElement);

    const scene = new THREE.Scene();
    const composer = new EffectComposer(renderer);
    composer.addPass(new RenderPass(scene, camera));
    const after = new AfterimagePass(opts.damp ?? 0.82);
    composer.addPass(after);
    const bloom = new UnrealBloomPass(new THREE.Vector2(320, 240), opts.bloom ?? 0.9, 0.45, 0);
    composer.addPass(bloom);
    composer.addPass(new OutputPass());

    let w = 4, h = 3;
    const resize = () => {
        w = box.clientWidth || w;
        h = box.clientHeight || h;
        renderer.setSize(w, h, false);
        composer.setSize(w, h);
        if (camera instanceof THREE.PerspectiveCamera) {
            camera.aspect = w / h;
            camera.updateProjectionMatrix();
        }
    };
    resize();
    const ro = new ResizeObserver(resize);
    ro.observe(box);

    let lastDraw = 0;
    const costs: number[] = [];
    const render = () => {
        const t0 = performance.now();
        composer.render();
        costs.push(performance.now() - t0);
        if (costs.length < 20) return;
        const avg = costs.reduce((a, b) => a + b, 0) / costs.length;
        costs.length = 0;
        if (avg > 22) {
            if (level < LEVELS.length - 1) {
                level++;
                renderer.setPixelRatio(LEVELS[level]);
                resize();
            } else if (bloom.enabled) {
                bloom.enabled = false; // 一番下の解像度でも重ければ、にじみを切る
            }
        }
    };

    let inView = true;
    const io = new IntersectionObserver(([e]) => { inView = e.isIntersecting; });
    io.observe(box);

    return {
        renderer,
        scene,
        composer,
        after,
        visible: () => inView && !document.hidden,
        due: (now: number) => {
            if (!inView || document.hidden || now - lastDraw < 1000 / 30) return false;
            lastDraw = now;
            return true;
        },
        render,
        aspect: () => w / h,
        dispose: () => {
            io.disconnect();
            ro.disconnect();
            composer.dispose();
            scene.traverse((o) => {
                const g = (o as THREE.Mesh).geometry;
                if (g) g.dispose();
            });
            renderer.dispose();
            if (renderer.domElement.parentNode === box) box.removeChild(renderer.domElement);
        },
    };
}

/** 線の材質(重なると明るくなる足し算の光) */
export function glowLine(color: number, opacity = 0.95) {
    return new THREE.LineBasicMaterial({ color, transparent: true, opacity, blending: THREE.AdditiveBlending, depthWrite: false });
}

/** [x1,y1,z1, x2,y2,z2, ...] の線の組から LineSegments を作る */
export function linesFrom(points: number[], material: THREE.Material) {
    const g = new THREE.BufferGeometry();
    g.setAttribute("position", new THREE.Float32BufferAttribute(points, 3));
    return new THREE.LineSegments(g, material);
}

/**
 * 押しっぱなしの操作(キーボードと画面のボタン)。ゲームが動いているあいだだけキーを取る。
 * keys の値は押している間 true。
 */
export type Keys = { left: boolean; right: boolean; up: boolean; down: boolean; fire: boolean };
export function newKeys(): Keys {
    return { left: false, right: false, up: false, down: false, fire: false };
}
const KEYMAP: Record<string, keyof Keys> = {
    ArrowLeft: "left", a: "left", ArrowRight: "right", d: "right", ArrowUp: "up", w: "up", ArrowDown: "down", s: "down", " ": "fire", Enter: "fire",
};
export function bindKeys(keys: Keys, active: () => boolean) {
    const on = (v: boolean) => (e: KeyboardEvent) => {
        const k = KEYMAP[e.key];
        if (!k || !active()) return;
        e.preventDefault(); // 遊んでいるあいだは、矢印キーとスペースでページが動かないようにする
        keys[k] = v;
    };
    const down = on(true), up = on(false);
    window.addEventListener("keydown", down);
    window.addEventListener("keyup", up);
    return () => {
        window.removeEventListener("keydown", down);
        window.removeEventListener("keyup", up);
    };
}
▶components/retro/GamePad.tsx67行
"use client";

import type { Keys } from "@/lib/retro/vectorScene";

type K = keyof Keys;
const DEFAULT_ARIA: Record<K, string> = { up: "前へ", left: "左へ回る", right: "右へ回る", down: "後ろへ", fire: "撃つ" };
const ARROW: Record<Exclude<K, "fire">, string> = { up: "↑", left: "←", right: "→", down: "↓" };

/**
 * スマホ用の押しっぱなしボタン(画面の下に置く)。押している間だけ keys[k] が true。
 * 指が外れた・画面が切り替わったときも必ず離した扱いにする(押しっぱなしで止まらなくなるのを防ぐ)。
 * - show: 出すボタン(ブロック崩しは ← → と撃つだけ、など)
 * - onPress: 押した瞬間に1回だけ呼ぶ(ヘビのように「向きを変える」操作は、押しっぱなしより押した瞬間で拾う)
 */
export default function GamePad({
    keys,
    disabled,
    fireLabel = "撃つ",
    show = ["up", "down", "left", "right", "fire"],
    aria = {},
    onPress,
}: {
    keys: Keys;
    disabled: boolean;
    fireLabel?: string;
    show?: K[];
    aria?: Partial<Record<K, string>>;
    onPress?: (k: K) => void;
}) {
    const hold = (k: K, label: string, area: string) => {
        const set = (v: boolean) => () => { keys[k] = v; };
        return (
            <button
                key={k}
                type="button"
                aria-label={aria[k] ?? (k === "fire" ? fireLabel : DEFAULT_ARIA[k])}
                disabled={disabled}
                onPointerDown={(e) => { e.currentTarget.setPointerCapture(e.pointerId); keys[k] = true; onPress?.(k); }}
                onPointerUp={set(false)}
                onPointerCancel={set(false)}
                onLostPointerCapture={set(false)}
                className="retro-btn select-none"
                style={{ gridArea: area, width: k === "fire" ? 104 : 52, height: 52, padding: 0, fontSize: k === "fire" ? "1rem" : "1.2rem", touchAction: "none" }}
            >
                {label}
            </button>
        );
    };
    const vertical = show.includes("up") || show.includes("down");
    return (
        <div className="mt-3 flex flex-wrap items-center justify-center gap-6">
            <div
                role="group"
                aria-label="移動"
                style={
                    vertical
                        ? { display: "grid", gridTemplateAreas: `". u ." "l . r" ". d ."`, gridTemplateColumns: "repeat(3, 52px)", gap: 6 }
                        : { display: "grid", gridTemplateAreas: `"l r"`, gridTemplateColumns: "repeat(2, 64px)", gap: 10 }
                }
            >
                {(["up", "left", "right", "down"] as const).filter((k) => show.includes(k)).map((k) => hold(k, ARROW[k], k[0]))}
            </div>
            {show.includes("fire") && hold("fire", fireLabel, "f")}
        </div>
    );
}
▶lib/retro/pixelGame.ts72行
/**
 * ドットのゲームの土台(Canvas 2D)。ブロック崩し・シューティング・ヘビで共通に使う。
 * - 当時と同じ小さな解像度(例 256x192)で描き、CSS で箱いっぱいに引き伸ばす(image-rendering: pixelated)
 *   → 線がにじまず、1ドットが大きな四角のまま拡大される
 * - 画面の外やタブが隠れているあいだは止める(電池とCPUを使わない)
 * - 1コマの経過時間は最大 1/20 秒で打ち切る(タブを戻したときに玉が壁を突き抜けないように)
 */
export type PixelScreen = {
    canvas: HTMLCanvasElement;
    ctx: CanvasRenderingContext2D;
    /** 描いてよいか(画面に見えていて、タブも表に出ている) */
    visible: () => boolean;
    /** 箱の中での指の位置を、ゲームの座標(0〜W, 0〜H)に直す */
    toGame: (clientX: number, clientY: number) => { x: number; y: number };
    dispose: () => void;
};

export function createPixelScreen(box: HTMLElement, W: number, H: number): PixelScreen {
    const canvas = document.createElement("canvas");
    canvas.width = W;
    canvas.height = H;
    Object.assign(canvas.style, { width: "100%", height: "100%", display: "block", imageRendering: "pixelated", touchAction: "none" });
    box.appendChild(canvas);
    const ctx = canvas.getContext("2d")!;
    ctx.imageSmoothingEnabled = false;

    let inView = true;
    const io = new IntersectionObserver(([e]) => { inView = e.isIntersecting; });
    io.observe(box);

    return {
        canvas,
        ctx,
        visible: () => inView && !document.hidden,
        toGame: (cx, cy) => {
            const r = canvas.getBoundingClientRect();
            return { x: ((cx - r.left) / r.width) * W, y: ((cy - r.top) / r.height) * H };
        },
        dispose: () => {
            io.disconnect();
            if (canvas.parentNode === box) box.removeChild(canvas);
        },
    };
}

/** 毎コマ step(dt秒) を呼ぶ。見えていないあいだは呼ばない。止めるときは戻り値を呼ぶ */
export function runLoop(step: (dt: number, now: number) => void, visible: () => boolean): () => void {
    let raf = 0;
    let last = performance.now();
    const tick = (now: number) => {
        raf = requestAnimationFrame(tick);
        const dt = Math.min(0.05, (now - last) / 1000);
        last = now;
        if (!visible()) return;
        step(dt, now);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
}

/**
 * 1文字ぶんのドット絵を描く。rows は "1" と "." の文字列(1 が点)。
 * 例: ["..1..", ".111.", "11111"]
 */
export function sprite(ctx: CanvasRenderingContext2D, rows: string[], x: number, y: number, color: string, scale = 1) {
    ctx.fillStyle = color;
    for (let r = 0; r < rows.length; r++) {
        const row = rows[r];
        for (let c = 0; c < row.length; c++) if (row[c] === "1") ctx.fillRect(Math.round(x + c * scale), Math.round(y + r * scale), scale, scale);
    }
}
▶app/retro/retro.css87行
/* レトロ・コンピュータ応用辞典(/retro)
   本文は白地・17px以上で読みやすく。レトロに振り切るのは「画面(デモ)」と見出し帯だけ。
   見出しはシャープな角ゴシック(丸ゴシックは幼いと言われたため・2026-09-26) */

.retro-dict {
  --font-display: var(--font-cool-jp), var(--font-geist-sans), "Hiragino Kaku Gothic ProN", "Hiragino Sans", sans-serif;
  --font-round-jp: var(--font-cool-jp);
  --font-tw-round: var(--font-cool-jp), "Hiragino Kaku Gothic ProN", sans-serif;
  --r-green: #33ff66;
  --r-amber: #ffb000;
  --r-ink: #0b0f0c;
}
.retro-dict .font-black { font-family: var(--font-display); }

/* 見出し帯(黒地に蛍光色・走査線) */
.retro-band {
  position: relative;
  background: radial-gradient(ellipse at 50% 40%, #13241a 0%, #070a08 75%);
  color: var(--r-green);
  overflow: hidden;
}
.retro-band::after {
  content: "";
  position: absolute; inset: 0; pointer-events: none;
  background: repeating-linear-gradient(to bottom, rgba(0,0,0,.28) 0 1px, transparent 1px 3px);
}
.retro-glow { text-shadow: 0 0 6px currentColor, 0 0 18px color-mix(in srgb, currentColor 45%, transparent); }
.retro-dot { font-family: var(--font-dot), var(--font-vt), monospace; letter-spacing: .04em; }
.retro-vt { font-family: var(--font-vt), ui-monospace, monospace; }

/* ── デモの枠 ───────────────────────── */
.retro-frame { margin: 0; }
.retro-bezel { border-radius: 22px; padding: clamp(10px, 2.4vw, 22px); box-shadow: inset 0 2px 0 rgba(255,255,255,.08), 0 10px 30px rgba(0,0,0,.25); }
.retro-screen {
  position: relative;
  aspect-ratio: 4 / 3;
  background: #000;
  border-radius: 16px / 12px;
  overflow: hidden;
  container-type: inline-size;
}
.retro-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 10px; }
.retro-label { font-weight: 900; font-size: 1rem; color: #111; margin-right: auto; }
.retro-buttons { display: flex; flex-wrap: wrap; gap: 8px; }
.retro-btn {
  min-height: 44px; padding: 0 14px; border-radius: 10px;
  border: 2px solid #1f2937; background: #fff; color: #111;
  font-weight: 900; font-size: .95rem;
  box-shadow: 0 3px 0 #1f2937;
}
.retro-btn:active { transform: translateY(2px); box-shadow: 0 1px 0 #1f2937; }
.retro-btn:disabled { opacity: .45; }
.retro-btn[aria-pressed="true"] { background: #111; color: var(--r-green); }
.retro-btn-main { background: #111; color: var(--r-green); }
.retro-note { width: 100%; font-size: 1.0625rem; color: #444; font-weight: 700; }

/* ── 画面の中身 ───────────────────────── */
/* --cols 桁が画面の横幅にちょうど収まる文字の大きさ(VT323 は1文字の幅が約0.5em) */
.term {
  --cols: 40;
  position: absolute; inset: 0;
  padding: 5cqw 5cqw;
  font-family: var(--font-vt), ui-monospace, monospace;
  font-size: calc(90cqw / var(--cols) / .5);
  line-height: 1.12;
  white-space: pre;
  overflow: hidden;
}
.term-jp { font-family: var(--font-dot), var(--font-vt), monospace; font-size: calc(90cqw / var(--cols) / .5); }
.phos-green { color: #33ff66; text-shadow: 0 0 .35em rgba(51,255,102,.55); }
.phos-amber { color: #ffb000; text-shadow: 0 0 .35em rgba(255,176,0,.55); }
.phos-white { color: #e8f0ff; text-shadow: 0 0 .35em rgba(200,220,255,.45); }

/* 走査線+周辺減光(バベルのとうの CRT と同じ作り) */
.crt::after {
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 5;
  background:
    repeating-linear-gradient(to bottom, rgba(0,0,0,.32) 0 1px, transparent 1px 3px),
    radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,.55) 100%);
}
.cursor-block { display: inline-block; width: .5em; background: currentColor; animation: r-blink 1s steps(1) infinite; }
@keyframes r-blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }

@media (prefers-reduced-motion: reduce) {
  .cursor-block { animation: none; }
}

04企画のネタ帳

重力で「引き寄せる」を見せる

サービスの中心(ロゴ)を星にして、周りのアイコンが軌道を描いて回る。集客や中心になるものの説明に合う。

1分で遊べる待ち時間ゲーム

操作が4つしかないので、説明なしで遊べる。イベントの受付や、読み込みの長いアプリの待ち時間に。

物理の授業の教材

重力と慣性がそのまま遊びになっている。数字を変えると軌道が変わるのを、手を動かして確かめられる。

あわせて読む

辞典の目次へ

ご相談

この演出を、うちのアプリやサイトにも入れたい

予算や進め方が決まっていない段階でも構いません。フォームは24時間受け付け、ご返答は2営業日以内です。