はじめに
メリークリスマス!たけのこ派の @Yu_yukk_Y です!
さて、クリスマスといえば?でまっさきに思い浮かぶものってなんでしょう?
やはり「聖戦」ですよね!!
今回はそんな血気盛んなみなさんのために、サクッと聖戦ができるとっておきのクソアプリを用意しました!
その名も「サクッときのこたけのこ戦争」です!
このアプリは現在Chrome Web Storeにリリース申請中です。リリースされるまでは下記のGithubから手順通りに遊んでみていただけると嬉しいです!
https://github.com/Yoshino-Yukitaro/kinoko-takenoko-battle-chrome-extension
このページに登場するデモ動画はすべて私の個人ブログ上で撮影しています。
サクッときのこたけのこ戦争って?
簡単に説明すると、Webサイトを破壊しながらきのこ派とたけのこ派で戦えるchrome拡張です。
プレイヤーはきのこ派とたけのこ派に分かれて、Webページ上で対応をぶっ放し、互いの信念のために戦います。まさに聖戦、、!
遊び方
起動は簡単!好きなWebページ上で右上のchrome拡張ボタンからサクッときのこたけのこ戦争を起動するだけです!
(※ http or httpsで始まるページでないと起動しません)
起動後は
・きのこはw・sキーでバズーカを移動、dキーできのこロケット発射!
・たけのこは↑・↓キーでバズーカを移動、←キーでたけのこロケット発射!
するだけ!1分間に相手陣営により多くのダメージを与えた方の勝ちです!
さぁ、Webページを舞台に互いの信念をかけて戦おうじゃないですか!!!
技術解説
ここからは工夫した点に絞って技術解説をします!
文字・敵陣営への当たり判定の管理
まず当たり判定を計算するために、対戦開始時にDOMからテキストを含む要素を探索し、idを与えたりブラウザ上での座標を取得したりしています。
interface LeafRect {
rightTop: { x: number; y: number };
rightBottom: { x: number; y: number };
leftx: number;
id: string;
}
const leaves: LeafRect[] = []; // 当たり判定管理用の配列
const traverse = (node: HTMLElement) => {
if (
node.nodeType === node.TEXT_NODE &&
node.textContent?.trim() !== "" &&
node.parentElement
) {
const parent = node.parentElement;
const rect = parent.getBoundingClientRect();
const x = maxWindowWidth - rect.right;
const id: string = crypto.randomUUID();
parent.id = id;
leaves.push({
rightTop: { x, y: rect.top },
rightBottom: { x, y: rect.bottom },
leftx: rect.left,
id,
});
}
for (const child of node.childNodes) {
traverse(child as HTMLElement);
}
};
traverse(document.body);
ここでテキストを含むエレメントを探索するtraverse
という関数では「深さ優先探索(DFS)」という探索アルゴリズムを用いています。
ここで管理した位置情報とたけのこ・きのこロケットの位置情報から当たり判定を計算しています。
文字が弾け飛ぶ演出
このアプリではロケットが文字にぶつかった時に文字が弾け飛びますが、実はこの演出はCSSを用いて表現しています。
まず、当たり判定があった時に1つのエレメントに2文字以上の文字が含まれていた場合、そのすべてをspan要素に分解して、元の要素のテキストを空文字列に更新します。
const spans = text.split("").map((char) => {
const span = document.createElement("span");
span.style.position = "relative";
span.style.transition = "1s all";
span.style.zIndex = "256";
span.style.display = "inline-block";
span.textContent = char;
span.id = crypto.randomUUID();
return span;
});
// leafの中身をspan要素に置き換える
leaf.textContent = "";
for (const span of spans) {
leaf.appendChild(span);
}
次に、弾け飛ぶ要素を計算し
// 半径100px以内にあるspan要素を抽出
const explodedSpans = spans.filter((span) => {
const rect = span.getBoundingClientRect();
if (kind === "kinoko") {
return (cx - rect.left) ** 2 + (rect.top - cy) ** 2 < 10000;
}
return (
(maxWindowWidth - rect.right - cx) ** 2 + (rect.top - cy) ** 2 <
10000
);
});
CSSのtransform
を用いて弾け飛ばすアニメーションを演出し、管理用の配列を更新します。
for (const span of explodedSpans) {
const calcDistance = () => {
if (kind === "kinoko") {
return Math.sqrt(
(span.getBoundingClientRect().left - cx) ** 2 +
(span.getBoundingClientRect().top - cy) ** 2,
);
}
return Math.sqrt(
(maxWindowWidth - span.getBoundingClientRect().right - cx) **
2 +
(span.getBoundingClientRect().top - cy) ** 2,
);
};
const calcTx = () => {
if (kind === "kinoko") {
return (
(110 / calcDistance()) *
(span.getBoundingClientRect().left - cx) +
(Math.random() - 0.5) * 5
);
}
return (
(110 / calcDistance()) *
(maxWindowWidth - span.getBoundingClientRect().right - cx) +
(Math.random() - 0.5) * 5
);
};
const ty =
(110 / calcDistance()) * (span.getBoundingClientRect().top - cy) +
(Math.random() - 0.5) * 5;
const rotate = (Math.random() - 0.5) * 360;
// 弾けとばすアニメーションを定義
span.style.transform = `translate(${calcTx() * vector}px, ${ty}px) rotate(${rotate}deg)`;
}
// 管理用の配列を更新する
this.leaves = [
...this.domRects.filter((rect) => rect.id !== leafId),
...spans.map((span) => {
const rect = span.getBoundingClientRect();
const x = maxWindowWidth - rect.right;
return {
rightTop: { x, y: rect.top },
rightBottom: { x, y: rect.bottom },
leftx: rect.left,
id: span.id,
};
}),
];
この処理の肝はspan.style.transition = "1s all";
とspan.style.transform = ``translate(${calcTx() * vector}px, ${ty}px) rotate(${rotate}deg)\``;
の2つのCSS設定です。
span.style.transition = "1s all";
は「すべてのtransition対象動作を1秒かけてゆっくり行う」という意味で
translate(${calcTx() * vector}px, ${ty}px) rotate(${rotate}deg);
は「要素をx座標${calcTx() * vector}px
、y座標${ty}px
動かし、${rotate}°
回転させる」という意味です。
この2つの合わせ技によって先ほどの弾け飛ぶアニメーションを実現しています。
動くロケットの管理
動くロケットとロケットを発射する処理は、動作管理用のカスタムフック(useTakenokoBazookaとuseKinokoBazooka)でrequestAnimationFrame
を用いることで実現しています。
// ロケットのデプロイとx座標の更新を行う
// x座標はstartTime(発射時間)とtimestamp(今の時間)の差分から行っている
const loop = useCallback(
(timestamp: number) => {
animationFrameIdRef.current = requestAnimationFrame(loop);
if (launchStanby) {
setLaunchStanby(false);
const rocketId = Math.random();
setRockets([
...rockets,
{
yAxis: cursorYAxis,
startTime: timestamp,
timestamp,
takenokoRocketId: rocketId,
exploded: false,
},
]);
}
setRockets((rockets) => {
return rockets.map((rocket) => {
return { ...rocket, timestamp };
});
});
},
[cursorYAxis, launchStanby, rockets],
);
useEffect(() => {
animationFrameIdRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(animationFrameIdRef.current as number);
}, [loop]);
requestAnimationFrame
とは、ブラウザにアニメーションを行いたいことを知らせ、指定した関数を呼び出して次の再描画の前にアニメーションを更新することを要求するブラウザ標準のメソッドです。
今回のアプリでは、このメソッドを用いることでロケットの座標を再描画し続け、滑らかな動作を実現しています。
また、このrequestAnimationFrame
メソッドは制限時間の管理でも使用しています。
// 残り時間は30000 - (nowTimeStamp - startTimeStamp)で計算しています。
const loop = useCallback(
(timestamp: number) => {
animationFrameIdRef.current = requestAnimationFrame(loop);
if (!isStart) return;
setNowTimeStamp(timestamp);
if (startTimeStamp === 0) {
setStartTimeStamp(timestamp);
return;
}
if (timestamp - startTimeStamp > LIMIT) {
setIsStart(false);
setStartTimeStamp(timestamp);
}
},
[startTimeStamp, isStart],
);
useEffect(() => {
animationFrameIdRef.current = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(animationFrameIdRef.current as number);
};
}, [loop]);
きのこロケットとたけのこロケットの見た目
実はCSSで描いてます🫠
特段解説することはなく、CSSのclip-path
プロパティを用いて地道に書きました。来年はお絵描きツール使いこなせる人間になりたい😭
まとめ
で、あなたはどっち派ですか?^_^