ロゴをレーザーで書いてから浮かべる
サービスのロゴを線だけの立体にして、最初にレーザーで1画ずつ書き、書き終わったら伸び縮みさせながら浮かべる。マウスで傾き、押すと弾けて戻る仕掛けを付けると、触って遊ぶ人が出てくる(このページのデモ)。
1960〜80年代 ・ 表現技法
画面を横じまで塗るのではなく、電子ビームを点から点へ動かして線を描く。暗い画面に細く鋭く光る緑の線と、尾を引く残光。ワイヤーフレームの見た目の元。

ふつうのブラウン管は、画面を上から横1本ずつなぞって絵を出します(ラスター方式)。ベクター画面はそうではなく、ビームを描きたい線の始点から終点へ直接動かします。画素を並べた絵ではないので、線はとても細く鋭く光ります。
1962年ごろ、MIT の学生たちが DEC の PDP-1 で動かしたゲーム Spacewar! は、Type 30 という表示装置を使っていました。1024×1024 の位置に1秒2万点ほど点を打つ装置で、線は点を並べて作っていました。蛍光体は P7 で、当たった瞬間は青白く、そのあと黄緑の残光が数秒残るため、動く物には軌跡が残りました。
1963年には、アイヴァン・サザランドの Sketchpad が、ライトペンで画面に図形を描いて動かせることを示しています。1971年に発表された Tektronix 4010 は「蓄積管」という、描いた線を光らせたまま覚えておけるブラウン管を使い、図面を安く表示できる端末として CAD などで広く使われました。
1979年には線で描くアーケードゲームが出て、1982年には画面を本体に内蔵した家庭用ゲーム機 Vectrex も登場します。Vectrex の画面は白黒で、色はプラスチックのシートを重ねて付けていました。
出典
緑の線(デモの既定)
オシロスコープ型の緑の蛍光体や蓄積管の緑をもとにした近い値
#39FF6EP7 の光った瞬間(青白)
資料の説明から見積もった近い値
#BFD8FFP7 の残光(黄緑)
数秒かけて消えていく色
#C8E05A蓄積管の線(明るい緑)
#8CFF7A蓄積管の地(暗い緑)
真っ黒ではない
#0F2A12白い線(家庭用・アーケード)
#F2F5FF| PDP-1 Type 30 | 1024×1024 の位置に点を打つ・1秒約2万点・線は点の並び |
|---|---|
| Tektronix 4010 | 蓄積管・見える範囲 1024×780・全消去だけ(1本は消せない) |
| 1979年のアーケード(デジタル方式) | 1024×768 の格子。斜めの線に小さなギザギザが出る |
| 描き直しの速さ(例) | 1983年のアーケードで約24ms ごと(約42回/秒)。一般的な値は未確認 |
| 線の明るさ | 描く速さで決まる。同じ明るさにするには、長い線ほど時間をかけて描く |
DEC の端末の文字をもとにした、細身のドット書体。英数字のみ。
表示の仕組みそのものに音はない(ゲームの効果音は別の回路)。デモでは蓄積管の全消去のときだけ小さな音を足している。
「▶ 再生」で動きます。音は「音なし」を押して切り替えたときだけ鳴ります。下のコードは、いま動いているデモのファイルそのものです。
components/retro/demos/vector-display.tsx314行"use client";
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import RetroFrame, { type DemoState } from "../RetroFrame";
import { noise } from "@/lib/retro/audio";
import { createVectorStage } from "@/lib/retro/vectorScene";
/**
* ベクター画面(線で直接描くディスプレイ)を Three.js で。開いた瞬間から動き続ける。
* - 線は LineSegments だけで描く(面は塗らない)=ビームが線をなぞる見た目
* - 残光は AfterimagePass(前のコマを少しずつ薄めて重ねる)、光のにじみは UnrealBloomPass(土台は lib/retro/vectorScene.ts)
* - リフレッシュ型 … 前のコマがすぐ薄れる。動く線が尾を引く
* - 蓄積管 … 前のコマが消えずに積もる。ときどき画面全体を光らせて一度に消す
* 絵(線の山並み・多面体・立体の文字・星)はすべてこのサイト用のオリジナル。
* 文字は最初にレーザーで1画ずつ書かれ、書き終わると伸び縮みしながら浮かぶ。
* マウス(指)の位置に向かって文字と画面が傾き、クリック(タップ)すると文字が弾けて元に戻る。
* 画面の外にあるときとタブが隠れているときは描かない(電池を食わない)。
*/
type Mode = "refresh" | "storage";
type Phosphor = "green" | "white" | "p7";
// 近い値。緑はオシロスコープ型・蓄積管の緑、白はアーケード、P7 は光った瞬間の青白
const PHOSPHOR: Record<Phosphor, { label: string; color: number }> = {
green: { label: "緑", color: 0x39ff6e },
white: { label: "白", color: 0xf2f5ff },
p7: { label: "青白(P7)", color: 0xbfd8ff },
};
const STORAGE = { line: 0x8cff7a, bg: 0x0f2a12 };
// 線だけで書いた文字(4×6 のます目の折れ線)
const GLYPHS: Record<string, number[][]> = {
N: [[0, 6, 0, 0, 4, 6, 4, 0]],
E: [[4, 0, 0, 0, 0, 6, 4, 6], [0, 3, 3, 3]],
X: [[0, 0, 4, 6], [4, 0, 0, 6]],
T: [[0, 0, 4, 0], [2, 0, 2, 6]],
C: [[4, 0, 0, 0, 0, 6, 4, 6]],
O: [[0, 0, 4, 0, 4, 6, 0, 6, 0, 0]],
D: [[0, 0, 0, 6, 3, 6, 4, 5, 4, 1, 3, 0, 0, 0]],
};
/** 文字を前と後ろの2枚にして、角を線でつないだ立体の文字 */
function textGeometry(text: string): THREE.BufferGeometry {
const pts: number[] = [];
const s = 0.09; // 伸び縮みで1.1倍になっても、4:3の画面の横幅に収まる大きさ
const w = text.length * 6 * s;
const P = (x: number, y: number, z: number) => [x * s - w / 2, (6 - y) * s - 0.36, z];
[...text].forEach((ch, i) => {
for (const pl of GLYPHS[ch] ?? []) {
for (let k = 0; k + 3 < pl.length; k += 2) {
const x1 = pl[k] + i * 6, y1 = pl[k + 1], x2 = pl[k + 2] + i * 6, y2 = pl[k + 3];
for (const z of [0.12, -0.12]) pts.push(...P(x1, y1, z), ...P(x2, y2, z));
pts.push(...P(x1, y1, 0.12), ...P(x1, y1, -0.12));
}
}
});
const g = new THREE.BufferGeometry();
g.setAttribute("position", new THREE.Float32BufferAttribute(pts, 3));
return g;
}
/** 線の山並み(格子)。高さは関数で決め、毎コマ手前へ流す */
const GRID = 28;
const SPAN = 14;
function height(x: number, z: number) {
return Math.sin(x * 0.55) * 0.45 + Math.cos(z * 0.45 + x * 0.2) * 0.55 + Math.sin((x + z) * 0.9) * 0.15;
}
function terrainPositions(out: Float32Array, scroll: number) {
let i = 0;
const step = SPAN / GRID;
const put = (gx: number, gz: number) => {
const x = -SPAN / 2 + gx * step;
const z = -SPAN + gz * step;
out[i++] = x;
out[i++] = -1.6 + height(x, z - scroll);
out[i++] = z + (scroll % step);
};
for (let gz = 0; gz <= GRID; gz++) for (let gx = 0; gx < GRID; gx++) { put(gx, gz); put(gx + 1, gz); }
for (let gx = 0; gx <= GRID; gx++) for (let gz = 0; gz < GRID; gz++) { put(gx, gz); put(gx, gz + 1); }
}
function Screen({ run, skip, sound, mode, phosphor }: DemoState & { mode: Mode; phosphor: Phosphor }) {
const boxRef = useRef<HTMLDivElement>(null);
const flashRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const box = boxRef.current;
if (!box || run === 0) return;
const color = mode === "storage" ? STORAGE.line : PHOSPHOR[phosphor].color;
const bg = mode === "storage" ? STORAGE.bg : 0x000000;
const camera = new THREE.PerspectiveCamera(60, 4 / 3, 0.1, 100);
camera.position.set(0, 0.4, 4.2);
// 描画・残光・にじみ・大きさの追従・1秒30コマまで・重いときの自動調整は共通の土台に任せる
const stage = createVectorStage(box, camera, { bg, damp: mode === "storage" ? 1.0 : 0.76, bloom: 0.5 });
const { scene, after } = stage;
const t0 = performance.now();
// 明るすぎると残光とにじみで画面全体がかすむ。線は控えめにして、文字が読めることを優先する
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.8, blending: THREE.AdditiveBlending, depthWrite: false });
const dim = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.22, blending: THREE.AdditiveBlending, depthWrite: false });
// 山並み
const terrainPos = new Float32Array((GRID + 1) * GRID * 2 * 3 * 2);
const terrainGeo = new THREE.BufferGeometry();
terrainGeo.setAttribute("position", new THREE.BufferAttribute(terrainPos, 3));
const terrain = new THREE.LineSegments(terrainGeo, dim);
scene.add(terrain);
// 回る多面体3つ
const shapes = [
new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.IcosahedronGeometry(0.75, 0)), mat),
new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.OctahedronGeometry(0.45, 0)), mat),
new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.DodecahedronGeometry(0.4, 0)), mat),
];
shapes[0].position.set(0, 0.55, 0);
shapes.forEach((m) => scene.add(m));
// 立体の文字(レーザーで1画ずつ書く)
const textGeo = textGeometry("NEXTCODE");
const textPos = textGeo.attributes.position.array as Float32Array;
const home = textPos.slice(); // 元の形
const SEGS = textPos.length / 6;
const text = new THREE.LineSegments(textGeo, mat);
text.position.set(0, -0.55, 0.6);
scene.add(text);
// 弾ける向き(線1本ごとにばらばらの方向)
const burstDir = new Float32Array(SEGS * 3);
for (let i = 0; i < SEGS; i++) {
burstDir[i * 3] = (Math.random() - 0.5) * 3;
burstDir[i * 3 + 1] = (Math.random() - 0.5) * 3;
burstDir[i * 3 + 2] = (Math.random() - 0.2) * 3;
}
// レーザー: 画面の上のほうの発射点から、今書いている点まで伸びる1本の線と、先端の光の点
const laserPos = new Float32Array(6);
const laserGeo = new THREE.BufferGeometry();
laserGeo.setAttribute("position", new THREE.BufferAttribute(laserPos, 3));
const laser = new THREE.LineSegments(laserGeo, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending, depthWrite: false }));
scene.add(laser);
const tipPos = new Float32Array(3);
const tipGeo = new THREE.BufferGeometry();
tipGeo.setAttribute("position", new THREE.BufferAttribute(tipPos, 3));
const tip = new THREE.Points(tipGeo, new THREE.PointsMaterial({ color: 0xffffff, size: 0.14, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
scene.add(tip);
const WRITE_SEC = 2.6; // 全部を書き終えるまで
// マウス・指の位置(-1〜1)と、弾けた時刻
const pointer = { x: 0, y: 0, tx: 0, ty: 0 };
let burstAt = -99;
const onMove = (e: PointerEvent) => {
const r = box.getBoundingClientRect();
pointer.tx = ((e.clientX - r.left) / r.width) * 2 - 1;
pointer.ty = ((e.clientY - r.top) / r.height) * 2 - 1;
};
const onDown = (e: PointerEvent) => { onMove(e); burstAt = (performance.now() - t0) / 1000; };
const onLeave = () => { pointer.tx = 0; pointer.ty = 0; };
box.addEventListener("pointermove", onMove);
box.addEventListener("pointerdown", onDown);
box.addEventListener("pointerleave", onLeave);
// 奥から飛んでくる星(点)
const STARS = 220;
const starPos = new Float32Array(STARS * 3);
for (let i = 0; i < STARS; i++) {
starPos[i * 3] = (Math.random() - 0.5) * 12;
starPos[i * 3 + 1] = Math.random() * 5 - 0.6;
starPos[i * 3 + 2] = -Math.random() * 30;
}
const starGeo = new THREE.BufferGeometry();
starGeo.setAttribute("position", new THREE.BufferAttribute(starPos, 3));
const stars = new THREE.Points(starGeo, new THREE.PointsMaterial({ color, size: 0.035, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false }));
scene.add(stars);
const place = (t: number) => {
terrainPositions(terrainPos, t * 2.2);
terrainGeo.attributes.position.needsUpdate = true;
shapes[0].rotation.set(t * 0.7, t * 1.1, t * 0.3);
shapes[1].position.set(Math.cos(t * 1.3) * 1.9, 0.7 + Math.sin(t * 2.1) * 0.35, Math.sin(t * 1.3) * 0.9);
shapes[1].rotation.set(t * 2, t * 1.4, 0);
shapes[2].position.set(Math.cos(t * 1.3 + Math.PI) * 1.9, 0.7 + Math.cos(t * 1.7) * 0.35, Math.sin(t * 1.3 + Math.PI) * 0.9);
shapes[2].rotation.set(0, t * 1.8, t * 1.2);
// レーザーで書く: 書いた本数だけ見せ、先端までレーザーを伸ばす
const written = Math.min(SEGS, Math.floor((t / WRITE_SEC) * SEGS));
textGeo.setDrawRange(0, written * 2);
const writing = written < SEGS;
laser.visible = tip.visible = writing;
if (writing && written > 0) {
text.updateMatrixWorld(); // 傾き・伸縮を反映してから先端の位置を出す
const k = (written - 1) * 6 + 3; // 最後に書いた線の終点
const v = new THREE.Vector3(textPos[k], textPos[k + 1], textPos[k + 2]).applyMatrix4(text.matrixWorld);
laserPos.set([-2.6, 2.4, -1, v.x, v.y, v.z]);
tipPos.set([v.x, v.y, v.z]);
laserGeo.attributes.position.needsUpdate = true;
tipGeo.attributes.position.needsUpdate = true;
}
// 書き終えたら、伸び縮みしながら浮かぶ
const alive = writing ? 0 : 1;
text.scale.set(1 + alive * 0.1 * Math.sin(t * 2.2), 1 + alive * 0.14 * Math.cos(t * 1.7), 1 + alive * 0.3 * Math.sin(t * 1.3));
text.position.y = -0.55 + alive * 0.12 * Math.sin(t * 1.1);
// マウスの方へ傾く(少しずつ追いかける)
pointer.x += (pointer.tx - pointer.x) * 0.08;
pointer.y += (pointer.ty - pointer.y) * 0.08;
text.rotation.y = Math.sin(t * 0.8) * 0.35 + pointer.x * 0.8;
text.rotation.x = Math.sin(t * 0.5) * 0.12 + pointer.y * 0.5;
// クリックで弾ける: 0.35秒で飛び散り、1.2秒かけて元の形へ戻る
const since = t - burstAt;
const amt = since < 0 ? 0 : since < 0.35 ? since / 0.35 : Math.max(0, 1 - (since - 0.35) / 1.2);
const ease = amt * amt * (3 - 2 * amt);
for (let i = 0; i < SEGS; i++) {
for (let j = 0; j < 6; j++) textPos[i * 6 + j] = home[i * 6 + j] + burstDir[i * 3 + (j % 3)] * ease;
}
textGeo.attributes.position.needsUpdate = true;
for (let i = 0; i < STARS; i++) {
starPos[i * 3 + 2] += 0.12;
if (starPos[i * 3 + 2] > 4) starPos[i * 3 + 2] = -30;
}
starGeo.attributes.position.needsUpdate = true;
camera.position.x = Math.sin(t * 0.4) * 0.5 + pointer.x * 0.7;
camera.position.y = 0.4 - pointer.y * 0.35;
camera.lookAt(0, 0, -1);
};
// 動きを減らす設定・「最後まで飛ばす」: 止まった1枚だけ描く
if (skip) {
after.enabled = false;
place(WRITE_SEC + 1.2);
stage.render();
return () => {
box.removeEventListener("pointermove", onMove);
box.removeEventListener("pointerdown", onDown);
box.removeEventListener("pointerleave", onLeave);
stage.dispose();
};
}
let raf = 0;
let nextErase = 6;
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
if (!stage.due(now)) return; // 1秒30コマまで・見えていないときは描かない
const t = (now - t0) / 1000;
place(t);
// 蓄積管: 積もった線を、ときどき画面全体を光らせて一度に消す
if (mode === "storage" && t > nextErase) {
nextErase = t + 6;
after.uniforms.damp.value = 0;
const f = flashRef.current;
if (f) {
f.style.transition = "none";
f.style.opacity = "0.85";
requestAnimationFrame(() => { f.style.transition = "opacity 500ms linear"; f.style.opacity = "0"; });
}
if (sound) noise(0.25, 0.3, 0, 300);
} else if (mode === "storage") {
after.uniforms.damp.value = 0.995; // ほぼ消えないが、真っ白に飽和しない
}
stage.render();
};
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
box.removeEventListener("pointermove", onMove);
box.removeEventListener("pointerdown", onDown);
box.removeEventListener("pointerleave", onLeave);
stage.dispose(); // 線の形(geometry)もまとめて片付ける
};
}, [run, skip, sound, mode, phosphor]);
return (
<div style={{ position: "absolute", inset: 0, background: mode === "storage" ? "#0f2a12" : "#000" }}>
<div ref={boxRef} style={{ position: "absolute", inset: 0, cursor: "crosshair", touchAction: "none" }} aria-label="線だけで描いた山並みと多面体と文字が動き続けるベクター画面。マウスや指を動かすと傾き、押すと文字が弾ける" role="img" />
<div ref={flashRef} aria-hidden style={{ position: "absolute", inset: 0, background: "#8cff7a", opacity: 0, pointerEvents: "none" }} />
</div>
);
}
export default function VectorDisplayDemo() {
const [mode, setMode] = useState<Mode>("refresh");
const [phosphor, setPhosphor] = useState<Phosphor>("green");
return (
<RetroFrame
label="ベクター画面(線で直接描く・Three.js)"
bezel="#222"
autoplay
below={() => (
<div className="mt-3 flex flex-col gap-2">
<p className="text-[1.0625rem] font-bold text-gray-800">画面の上でマウス(指)を動かすと傾き、押すと文字が弾けて戻ります。</p>
<div role="group" aria-label="方式の切り替え" className="flex flex-wrap gap-2">
<button type="button" aria-pressed={mode === "refresh"} onClick={() => setMode("refresh")} className="retro-btn">
リフレッシュ型(残光で尾を引く)
</button>
<button type="button" aria-pressed={mode === "storage"} onClick={() => setMode("storage")} className="retro-btn">
蓄積管(線が積もる・光って消える)
</button>
</div>
{mode === "refresh" && (
<div role="group" aria-label="蛍光体の色" className="flex flex-wrap gap-2">
{(Object.keys(PHOSPHOR) as Phosphor[]).map((p) => (
<button key={p} type="button" aria-pressed={phosphor === p} onClick={() => setPhosphor(p)} className="retro-btn">
{PHOSPHOR[p].label}
</button>
))}
</div>
)}
</div>
)}
>
{(s) => <Screen {...s} mode={mode} phosphor={phosphor} />}
</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; }
}
サービスのロゴを線だけの立体にして、最初にレーザーで1画ずつ書き、書き終わったら伸び縮みさせながら浮かべる。マウスで傾き、押すと弾けて戻る仕掛けを付けると、触って遊ぶ人が出てくる(このページのデモ)。
読み込み中に、蓄積管のように線を1本ずつ書き足していき、終わったら緑に光って消えて本番の画面に切り替わる。
位置の移動や相場の動きを、黄緑の尾を引く点で描くと、過去の動きが自然に目に残る。
画面の上でマウス(指)を動かすと傾き、押すと文字が弾けて戻ります。