chatGPT
RPGツクールみたいなゲームが作りたいな 作って
→
import React, { useEffect, useMemo, useRef, useState } from "react";
const W = 18;
const H = 12;
const TILE = 32;
const RAW_MAP = [
"##################",
"#....g....T......#",
"#..####..........#",
"#..#..#....~~....#",
"#..#..#....~~..C.#",
"#..#..#..........#",
"#..#..#####..#####",
"#.................#",
"#.....N.....T.....#",
"#.................#",
"#....S.......E....#",
"##################",
];
const tileInfo = {
"#": { name: "壁", walk: false, bg: "bg-stone-700", text: "" },
".": { name: "草地", walk: true, bg: "bg-emerald-200", text: "" },
"g": { name: "花畑", walk: true, bg: "bg-lime-200", text: "✿" },
"T": { name: "木", walk: false, bg: "bg-green-700", text: "♣" },
"~": { name: "水", walk: false, bg: "bg-sky-300", text: "≈" },
"S": { name: "始まりの石", walk: true, bg: "bg-indigo-200", text: "◆" },
"E": { name: "出口", walk: true, bg: "bg-amber-200", text: "◎" },
"C": { name: "宝箱", walk: true, bg: "bg-yellow-200", text: "▣" },
"N": { name: "村人", walk: true, bg: "bg-rose-200", text: "!" },
};
const DIRS = {
ArrowUp: [0, -1],
ArrowDown: [0, 1],
ArrowLeft: [-1, 0],
ArrowRight: [1, 0],
w: [0, -1],
s: [0, 1],
a: [-1, 0],
d: [1, 0],
W: [0, -1],
S: [0, 1],
A: [-1, 0],
D: [1, 0],
};
const enemyPool = [
{ name: "ぷるスライム", hp: 18, atk: 5, exp: 8, gold: 5, art: "◌" },
{ name: "夜のコウモリ", hp: 22, atk: 6, exp: 11, gold: 7, art: "⌁" },
{ name: "影きのこ", hp: 26, atk: 7, exp: 14, gold: 10, art: "♟" },
];
const initialState = {
player: { x: 2, y: 10, dir: "up" },
hero: { name: "ミナト", lv: 1, hp: 36, maxHp: 36, mp: 10, maxMp: 10, atk: 8, exp: 0, next: 20, gold: 0 },
inventory: { potion: 2, herb: 0, key: 0 },
flags: { chest: false, npcTalked: false, cleared: false },
log: ["矢印キー/WASDで移動、Space/Enterで調べる。"],
};
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function roll(min, max) {
return Math.floor(Math.random() * (max - min 1)) min;
}
function isWalkable(x, y) {
if (x < 0 || y < 0 || x >= W || y >= H) return false;
const ch = RAW_MAP[y][x];
return tileInfo[ch]?.walk;
}
function getTile(x, y) {
return RAW_MAP[y]?.[x] ?? "#";
}
function addLog(state, line) {
return { ...state, log: [line, ...state.log].slice(0, 6) };
}
function levelUpIfNeeded(state) {
let hero = { ...state.hero };
let logs = [];
while (hero.exp >=
hero.next) {
hero.exp -=
hero.next;
hero.lv = 1;
hero.next = Math.floor(
hero.next * 1.45 8);
hero.maxHp = 9;
hero.maxMp = 3;
hero.atk = 3;
hero.hp = hero.maxHp;
hero.mp = hero.maxMp;
logs.push(`レベル${
hero.lv}に上がった! HPとMPが全回復した。`);
}
return { ...state, hero, log: [...logs, ...state.log].slice(0, 6) };
}
function StatusBar({ label, value, max }) {
const pct = clamp((value / max) * 100, 0, 100);
return (
<div className="space-y-1">
<div className="flex justify-between text-xs text-slate-700">
<span>{label}</span>
<span>{value}/{max}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200">
<div className="h-full rounded-full bg-slate-800 transition-all" style={{ width: `${pct}%` }} />
</div>
</div>
);
}
export default function RPGTkoolLikeMiniGame() {
const [game, setGame] = useState(() => {
try {
const saved = localStorage.getItem("mini-rpg-save-v1");
return saved ? JSON.parse(saved) : initialState;
} catch {
return initialState;
}
});
const [mode, setMode] = useState("field");
const [dialog, setDialog] = useState(null);
const [battle, setBattle] = useState(null);
const [shake, setShake] = useState(false);
const [toast, setToast] = useState("小さな森のRPG");
const fieldRef = useRef(null);
const currentTile = getTile(game.player.x, game.player.y);
const save = () => {
localStorage.setItem("mini-rpg-save-v1", JSON.stringify(game));
setToast("セーブしました");
};
const reset = () => {
localStorage.removeItem("mini-rpg-save-v1");
setGame(initialState);
setMode("field");
setDialog(null);
setBattle(null);
setToast("最初から始めました");
};
const openDialog = (lines, afterClose) => {
setDialog({ lines, index: 0, afterClose });
setMode("dialog");
};
const closeDialog = () => {
const afterClose = dialog?.afterClose;
setDialog(null);
setMode("field");
if (afterClose) afterClose();
};
const nextDialog = () => {
if (!dialog) return;
if (dialog.index < dialog.lines.length - 1) {
setDialog({ ...dialog, index: dialog.index 1 });
} else {
closeDialog();
}
};
const startBattle = (enemy = enemyPool[roll(0, enemyPool.length - 1)]) => {
setBattle({ enemy: { ...enemy, maxHp: enemy.hp }, turn: "hero", text: `${
enemy.name}が現れた!` });
setMode("battle");
};
const maybeEncounter = (x, y) => {
const ch = getTile(x, y);
if ([".", "g"].includes(ch) && Math.random() < 0.12) {
setTimeout(() => startBattle(), 80);
}
};
const move = (key) => {
if (mode !== "field") return;
const dir = DIRS[key];
if (!dir) return;
const [dx, dy] = dir;
const dirName = dx === -1 ? "left" : dx === 1 ? "right" : dy === -1 ? "up" : "down";
const nx = game.player.x dx;
const ny = game.player.y dy;
if (!isWalkable(nx, ny)) {
setGame((s) => addLog({ ...s, player: { ...s.player, dir: dirName } }, "進めない。"));
return;
}
setGame((s) => ({ ...s, player: { x: nx, y: ny, dir: dirName } }));
maybeEncounter(nx, ny);
};
const interact = () => {
if (mode === "dialog") return nextDialog();
if (mode !== "field") return;
const { x, y } = game.player;
const targets = [
[x, y],
[x, y - 1],
[x 1, y],
[x, y 1],
[x - 1, y],
];
for (const [tx, ty] of targets) {
const ch = getTile(tx, ty);
if (ch === "N") {
openDialog([
"村人:この森は、出口に近づくほど魔物が出るよ。",
"村人:宝箱の薬草を取っておくと安心だ。",
"村人:……あと、出口の石はなぜか人を見る。気まずい。",
]);
setGame((s) => ({ ...s, flags: { ...s.flags, npcTalked: true } }));
return;
}
if (ch === "C") {
if (game.flags.chest) {
openDialog(["宝箱はからっぽだ。からっぽにも、からっぽなりの圧がある。"]);
} else {
setGame((s) => ({
...s,
inventory: { ...s.inventory, potion: s.inventory.potion 1, herb: s.inventory.herb 2 },
flags: { ...s.flags, chest: true },
log: ["ポーション×1、薬草×2を手に入れた!", ...s.log].slice(0, 6),
}));
openDialog(["宝箱を開けた。", "ポーション×1、薬草×2を手に入れた!"]);
}
return;
}
if (ch === "S") {
openDialog([
"石碑:小さな勇者よ。",
"石碑:ゲームが完成するかは、最初の一歩を置けるかで決まる。",
"石碑:つまり、もう半分できている。",
]);
return;
}
if (ch === "E") {
if (
game.hero.lv >= 2 || game.flags.npcTalked) {
setGame((s) => ({ ...s, flags: { ...s.flags, cleared: true } }));
openDialog([
"出口の石が、低く鳴った。",
"森の向こうに、次のマップの気配がある。",
"クリア! ここから町、洞窟、仲間加入イベントを追加できます。",
]);
} else {
openDialog(["出口の石:まだ早い。", "出口の石:せめて村人の話を聞いてから行け。"]);
}
return;
}
}
openDialog(["特に何もない。", "……いや、何もない場所があるから、道が見える。"]);
};
const heroAttack = () => {
if (!battle || battle.turn !== "hero") return;
const dmg = roll(game.hero.atk - 2, game.hero.atk 4);
const enemyHp = Math.max(0, battle.enemy.hp - dmg);
const next = { ...battle, enemy: { ...battle.enemy, hp: enemyHp }, text: `${
game.hero.name}の攻撃! ${dmg}ダメージ。`, turn: "enemy" };
setBattle(next);
if (enemyHp <= 0) {
const gained = { ...game.hero, exp: game.hero.exp battle.enemy.exp, gold:
game.hero.gold battle.enemy.gold };
let nextGame = {
...game,
hero: gained,
log: [`${
battle.enemy.name}を倒した! EXP${battle.enemy.exp} / ${
battle.enemy.gold}G`, ...game.log].slice(0, 6),
};
nextGame = levelUpIfNeeded(nextGame);
setGame(nextGame);
setBattle({ ...next, text: `${
battle.enemy.name}を倒した!`, turn: "end" });
setTimeout(() => {
setBattle(null);
setMode("field");
}, 900);
return;
}
setTimeout(enemyTurn, 700, next.enemy);
};
const castHeal = () => {
if (!battle || battle.turn !== "hero") return;
if (
game.hero.mp < 4) {
setBattle({ ...battle, text: "MPが足りない。" });
return;
}
const heal = roll(12, 18);
setGame((s) => ({
...s,
hero: { ...s.hero, mp:
s.hero.mp - 4, hp: clamp(s.hero.hp heal, 0, s.hero.maxHp) },
}));
const next = { ...battle, text: `ヒール! HPが${heal}回復した。`, turn: "enemy" };
setBattle(next);
setTimeout(enemyTurn, 700, next.enemy);
};
const usePotion = () => {
if (!battle || battle.turn !== "hero") return;
if (game.inventory.potion <= 0) {
setBattle({ ...battle, text: "ポーションがない。" });
return;
}
setGame((s) => ({
...s,
inventory: { ...s.inventory, potion: s.inventory.potion - 1 },
hero: { ...s.hero, hp: clamp(s.hero.hp 24, 0, s.hero.maxHp) },
}));
const next = { ...battle, text: "ポーションを使った。HPが24回復!", turn: "enemy" };
setBattle(next);
setTimeout(enemyTurn, 700, next.enemy);
};
const enemyTurn = (enemy) => {
setShake(true);
setTimeout(() => setShake(false), 180);
const dmg = roll(Math.max(1, enemy.atk - 2), enemy.atk 3);
setGame((s) => {
const hp = Math.max(0, s.hero.hp - dmg);
return { ...s, hero: { ...s.hero, hp } };
});
setBattle((b) => (b ? { ...b, text: `${
enemy.name}の攻撃! ${dmg}ダメージ。`, turn: "hero" } : b));
};
useEffect(() => {
if (mode === "battle" && game.hero.hp <= 0) {
setBattle((b) => (b ? { ...b, text: "倒れてしまった……始まりの石へ戻る。", turn: "end" } : b));
setTimeout(() => {
setGame((s) => ({
...s,
player: { x: 2, y: 10, dir: "up" },
hero: { ...s.hero, hp: Math.ceil(s.hero.maxHp / 2), mp: Math.ceil(s.hero.maxMp / 2) },
log: ["始まりの石で目を覚ました。", ...s.log].slice(0, 6),
}));
setBattle(null);
setMode("field");
}, 1100);
}
}, [game.hero.hp, mode]);
useEffect(() => {
const onKey = (e) => {
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", " ", "Enter", "w", "a", "s", "d", "W", "A", "S", "D"].includes(e.key)) {
e.preventDefault();
}
if (e.key === " " || e.key === "Enter") interact();
else move(e.key);
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
const mapTiles = useMemo(() => {
return RAW_MAP.flatMap((row, y) => row.split("").map((ch, x) => ({ ch, x, y, info: tileInfo[ch] })));
}, []);
return (
<div className="min-h-screen bg-slate-100 p-4 text-slate-900">
<div className="mx-auto max-w-6xl space-y-4">
<header className="rounded-2xl bg-white p-4 shadow-sm">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-slate-500">Mini RPG Prototype</p>
<h1 className="text-2xl font-black tracking-tight">小さな森と始まりの石</h1>
<p className="text-sm text-slate-600">RPGツクール風の見下ろし探索RPG。矢印キー/WASDで移動、Space/Enterで調べる。</p>
</div>
<div className="flex flex-wrap gap-2">
<button onClick={save} className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-bold text-white shadow-sm active:scale-95">セーブ</button>
<button onClick={reset} className="rounded-xl bg-white px-4 py-2 text-sm font-bold text-slate-900 ring-1 ring-slate-300 active:scale-95">リセット</button>
</div>
</div>
</header>
<main className="grid gap-4 lg:grid-cols-[1fr_320px]">
<section className="rounded-2xl bg-white p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between">
<div className="rounded-full bg-slate-100 px-3 py-1 text-xs font-bold text-slate-600">現在地:{tileInfo[currentTile]?.name}</div>
<div className="rounded-full bg-amber-100 px-3 py-1 text-xs font-bold text-amber-800">{toast}</div>
</div>
<div
ref={fieldRef}
className={`relative mx-auto overflow-hidden rounded-2xl border-4 border-slate-800 bg-slate-800 shadow-inner ${shake ? "translate-x-1" : ""}`}
style={{ width: W * TILE, height: H * TILE }}
>
{
mapTiles.map(({ ch, x, y, info }) => {
const openedChest = ch === "C" && game.flags.chest;
const clearedExit = ch === "E" && game.flags.cleared;
return (
<div
key={`${x}-${y}`}
className={`absolute flex select-none items-center justify-center text-lg font-black ${
info.bg} ${ch === "#" ? "text-slate-500" : "text-slate-800"}`}
style={{ left: x * TILE, top: y * TILE, width: TILE, height: TILE }}
>
{openedChest ? "□" : clearedExit ? "★" : info.text}
</div>
);
})}
<div
className="absolute z-10 flex items-center justify-center rounded-full border-2 border-white bg-slate-950 text-lg font-black text-white shadow-lg transition-all duration-100"
style={{ left: game.player.x * TILE 3, top: game.player.y * TILE 3, width: TILE - 6, height: TILE - 6 }}
>
{game.player.dir === "up" ? "▲" : game.player.dir === "down" ? "▼" : game.player.dir === "left" ? "◀" : "▶"}
</div>
{mode === "dialog" && dialog && (
<div className="absolute inset-x-4 bottom-4 z-20 rounded-2xl border-2 border-slate-900 bg-white/95 p-4 shadow-xl">
<p className="min-h-[48px] text-base font-bold leading-relaxed">{dialog.lines[dialog.index]}</p>
<p className="mt-2 text-right text-xs text-slate-500">Space / Enterで次へ</p>
</div>
)}
{mode === "battle" && battle && (
<div className="absolute inset-0 z-30 flex items-center justify-center bg-slate-950/80 p-5 text-white">
<div className="w-full max-w-md rounded-2xl border-2 border-white bg-slate-900 p-5 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-400">Battle</p>
<h2 className="text-2xl font-black">{
battle.enemy.name}</h2>
</div>
<div className="text-5xl">{
battle.enemy.art}</div>
</div>
<StatusBar label="Enemy HP" value={battle.enemy.hp} max={battle.enemy.maxHp} />
<div className="mt-4 rounded-xl bg-white p-3 font-bold text-slate-900">{battle.text}</div>
<div className="mt-4 grid grid-cols-3 gap-2">
<button disabled={battle.turn !== "hero"} onClick={heroAttack} className="rounded-xl bg-white px-3 py-2 text-sm font-black text-slate-900 disabled:opacity-40">攻撃</button>
<button disabled={battle.turn !== "hero"} onClick={castHeal} className="rounded-xl bg-white px-3 py-2 text-sm font-black text-slate-900 disabled:opacity-40">ヒール</button>
<button disabled={battle.turn !== "hero"} onClick={usePotion} className="rounded-xl bg-white px-3 py-2 text-sm font-black text-slate-900 disabled:opacity-40">薬</button>
</div>
</div>
</div>
)}
</div>
<div className="mt-3 grid grid-cols-3 gap-2 md:hidden">
<div />
<button onClick={() => move("ArrowUp")} className="rounded-xl bg-slate-900 p-3 font-black text-white">↑</button>
<div />
<button onClick={() => move("ArrowLeft")} className="rounded-xl bg-slate-900 p-3 font-black text-white">←</button>
<button onClick={interact} className="rounded-xl bg-amber-400 p-3 font-black text-slate-950">調</button>
<button onClick={() => move("ArrowRight")} className="rounded-xl bg-slate-900 p-3 font-black text-white">→</button>
<div />
<button onClick={() => move("ArrowDown")} className="rounded-xl bg-slate-900 p-3 font-black text-white">↓</button>
<div />
</div>
</section>
<aside className="space-y-4">
<section className="rounded-2xl bg-white p-4 shadow-sm">
<h2 className="mb-3 text-lg font-black">ステータス</h2>
<div className="mb-3 rounded-2xl bg-slate-100 p-3">
<div className="flex items-center justify-between font-black">
<span>{
game.hero.name}</span>
<span>Lv {
game.hero.lv}</span>
</div>
<div className="mt-3 space-y-3">
<StatusBar label="HP" value={game.hero.hp} max={game.hero.maxHp} />
<StatusBar label="MP" value={
game.hero.mp} max={game.hero.maxMp} />
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="rounded-xl bg-slate-50 p-2"><b>ATK</b><br />{game.hero.atk}</div>
<div className="rounded-xl bg-slate-50 p-2"><b>EXP</b><br />{game.hero.exp}/{
game.hero.next}</div>
<div className="rounded-xl bg-slate-50 p-2"><b>GOLD</b><br />{
game.hero.gold}G</div>
<div className="rounded-xl bg-slate-50 p-2"><b>進行</b><br />{game.flags.cleared ? "クリア" : "探索中"}</div>
</div>
</section>
<section className="rounded-2xl bg-white p-4 shadow-sm">
<h2 className="mb-3 text-lg font-black">もちもの</h2>
<div className="space-y-2 text-sm">
<div className="flex justify-between rounded-xl bg-slate-50 p-2"><span>ポーション</span><b>{game.inventory.potion}</b></div>
<div className="flex justify-between rounded-xl bg-slate-50 p-2"><span>薬草</span><b>{game.inventory.herb}</b></div>
<div className="flex justify-between rounded-xl bg-slate-50 p-2"><span>鍵</span><b>{game.inventory.key}</b></div>
</div>
</section>
<section className="rounded-2xl bg-white p-4 shadow-sm">
<h2 className="mb-3 text-lg font-black">ログ</h2>
<div className="space-y-2 text-sm text-slate-700">
{
game.log.map((line, i) => (
<p key={`${line}-${i}`} className="rounded-xl bg-slate-50 p-2">{line}</p>
))}
</div>
</section>
<section className="rounded-2xl bg-slate-900 p-4 text-white shadow-sm">
<h2 className="mb-2 text-lg font-black">改造ポイント</h2>
<p className="text-sm leading-relaxed text-slate-200">
RAW_MAPを書き換えるとマップを変更できます。N/C/E/Sを増やせば、NPC・宝箱・出口・石碑イベントを追加できます。
</p>
</section>
</aside>
</main>
</div>
</div>
);
}