前説
先日、人生初の作品を、人生初のRustプログラミングで再現してみたという記事を読み、自分自身の人生初の作品は何だったのだろうかと思い返した。
入社してから手掛けたものは、いずれも既存のプログラムだし(何より公開できない)、敢えて挙げるなら入社1年目の忘年会で余興のために作ったビンゴマシン1だろうか・・・
いや、それよりも前・・・学生時代に作ったものがあった。
正直なところ、極めて不真面目な学生だった私は、3年次に情報系のゼミに配属されるまでプログラミングというものに触れたことがなかった2。
それ故に担当の教授はさぞ頭を抱えたことだろう。なにせ覚えたてのSQL
を嬉々として本番環境で試し、危うくマスタデータを消し飛ばすような問題児3だ。
「何でもいいから言語をひとつ覚えて作品を作ってみなさい」
そんなミッションを課され、とりあえずカッコいいWeb
ページを作ろうと思い立った私4はHTML
とCSS
に手を出し、そして最後にJavascript
を覚えた5。
そしてようやく作りたいものが浮かんだ。
「ゼルダの伝説 風のタクト6」の海戦ゲームである。
割と有名なゲームなので知っている方もいるかもしれないが、初見の方のためにルールを説明しておくと・・・
- 8×8マスの中に3隻の船7が隠れている
- 3隻の船はそれぞれ3マス、4マス、5マスの長さで、縦もしくは横向きである(=斜めはない)
- プレイヤーは大砲を撃ち、対象の完全撃沈を目指す
- 大砲は最大24発まで発射可能で、24発以内に完全撃沈すれば勝利
とまあこんな感じのゲームである。
そこで分からないなりに頭を働かせ、上記の言語を駆使してなんとか完成はさせたのだが、このゲームは初級者にはちょっと厳しめのポイントがいくつかある。
ぱっと挙がるだけでも下記の通りである。
- ランダムに生成される3隻の船は盤面内に必ず収まっていなければならない
- 船同士の座標が被ってはならない
このあたりは勘所を押さえていないとドツボにハマりがちで、ご多分に漏れず私もハマった。
**「盤外の新世界へとオンザクルーズする船」や「コバンザメの如く他船に重なる不届き者」**が発生し、「世はまさに大海賊時代」の様相を呈していたが、残念なことに全てのバグを改修しきれずに時間切れ。
そう、未完のまま終わってしまったのである。
そこで今回、無念の思いを抱えて電子の海を彷徨う海賊たちの怨念、もといバグ供養の意味も込めて、私が普段愛用しているTypescript
とReact
を使って、ついでに最近かじったNeumorphism
(ニューモフィズム)とかいうハイカラなデザインもゴリゴリに混ぜ込んでみる。
完成品
先に動くものを見た方が分かりがいいと思うので、画面キャプチャを載せる。
完成品はGitHub Pagesのこちらのページで公開中(PC推奨。スマホも対応予定)。
いちおう上記で挙げたバグを回避しつつ、なるべくシンプルな作りに仕上げた。
ソースコード
ソースもGitHubのこちらのリポジトリで公開しているので、コアとなるGameBoard.tsx
とTargetInfo.ts
のみ掲載。
TargetInfo.ts
/**
* ターゲットクラス
*/
export default class TargetInfo {
// 位置情報
cells : { row : number, col : number }[] = [];
// 向き(0:up, 1:right, 2:down, 3:left)
direction : number = -1;
// 沈没フラグ
isBroken : boolean = false;
}
/**
* 指定したマス目内に存在するTargetInfoを生成する
* @param lines 行数
*/
export const GenerateTargets = (lines : number) : TargetInfo[] => {
if(lines < 0) return [];
let result : TargetInfo[] = [];
// 長さが3,4,5のTargetInfoを生成する
result.push(GenerateTarget(5,lines, result));
result.push(GenerateTarget(4,lines, result));
result.push(GenerateTarget(3,lines, result));
return result;
}
/**
* TargetInfoを生成
* @param length 長さ
* @param lines 行数
* @param currentTargets 既に生成されているTargetInfoリスト
*/
export const GenerateTarget = (length : number, lines : number, currentTargets? : TargetInfo[]) : TargetInfo => {
let result : TargetInfo = new TargetInfo();
let created : boolean = false;
currentTargets = currentTargets || [];
while(!created){
// ランダムに座標と向きを生成する
const row : number = Math.floor(Math.random()*(lines));
const col : number = Math.floor(Math.random()*(lines));
const direction : number = Math.floor(Math.random()*(4));
result.direction = direction;
// 先に設定されたTargetInfoの座標と被っているかチェック
if(currentTargets.length > 0){
let duplicate : boolean = false;
switch(direction){
// up
case 0:
duplicate = currentTargets.some((t) => {
return !!t.cells.find((c) => c.col === col && c.row >= row - length)
});
break;
// right
case 1:
duplicate = currentTargets.some((t) => {
return !!t.cells.find((c) => c.row === row && c.col <= col + length)
});
break;
// down
case 2:
duplicate = currentTargets.some((t) => {
return !!t.cells.find((c) => c.col === col && c.row <= row + length)
});
break;
// left
case 3:
duplicate = currentTargets.some((t) => {
return !!t.cells.find((c) => c.row === row && c.col >= col - length)
});
break;
}
if(duplicate) continue;
}
// 生成された座標と向きがマス内に完全に収まるかチェック
switch(direction){
// up
case 0:
if(length <= row+1){
created = true;
for(let i = 0; i < length; i++){
result.cells.push({ row : row-i, col })
}
}
break;
// right
case 1:
if(col + length <= lines){
created = true;
for(let i = 0; i < length; i++){
result.cells.push({ col : col+i, row })
}
}
break;
// down
case 2:
if(row + length <= lines){
created = true;
for(let i = 0; i < length; i++){
result.cells.push({ row : row+i, col })
}
}
break;
// left
case 3:
if(length <= col+1){
created = true;
for(let i = 0; i < length; i++){
result.cells.push({ col : col-i, row })
}
}
break;
}
}
return result;
}
GameBoard.tsx
import React from 'react';
import './GameBoard.css';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faShip, faTimes, faCertificate, faCrosshairs, faWater } from "@fortawesome/free-solid-svg-icons";
import TargetInfo, { GenerateTargets } from '../modules/TargetInfo';
interface Props {
lines? : number,
}
interface State {
selectedCell : { col : number, row : number }[],
targets : TargetInfo[],
max : number,
}
class GameBoard extends React.PureComponent<Props, State> {
constructor(props : Props) {
super(props);
this.state = {
selectedCell : [],
targets : [],
max : 24,
}
}
componentDidMount = () => {
this?.reset();
}
/**
* ゲームリセット
*/
reset = () => {
this.setState({
targets : [...GenerateTargets(8)],
selectedCell : [],
})
}
/**
* セル押下時処理
* @param row
* @param col
*/
onClickCell = (row : number, col : number) => {
let targets = Array.from(this.state.targets);
const max = this.state.max;
const selectedCell = Array.from(this.state.selectedCell);
const aliveShips : number = targets.filter(t => !t.isBroken).length
const isFinished : boolean = selectedCell.length === max || aliveShips === 0;
if(isFinished) {
alert('もう一度遊ぶには「リセット」を押してね!!');
return
};
// 既に選択済のマスかチェック
const findIndex : number = selectedCell.findIndex((c) => c.row === row && c.col === col);
if(findIndex < 0){
selectedCell.push({row, col});
} else {
return;
}
// 選択済のマスから大破したターゲットがいるか探索
targets.forEach((t) => {
if(t.isBroken) return;
const allHit : boolean = selectedCell.filter((c) => {
return !!(t.cells.find((tc) => tc.row === c.row && tc.col === c.col));
}).length === t.cells.length;
// 大破の場合はフラグを立てておく
t.isBroken = allHit;
})
this.setState({
selectedCell,
targets
})
}
render = () => {
const { selectedCell, targets, max } = this.state;
const aliveShips : number = targets.filter(t => !t.isBroken).length
const isFinished : boolean = selectedCell.length === max || aliveShips === 0;
let { lines } = this.props;
lines = lines || 8;
let rows : JSX.Element[] = [];
for(let i = 0; i < lines; i++){
let cells : JSX.Element[] = [];
for(let j= 0; j < lines; j++){
const selected : boolean = selectedCell.some((c) => c.row === i && c.col === j);
const isTarget : boolean = targets.some((t) => {
return !!(t.cells.find((c) => c.row === i && c.col === j));
});
// デフォルトでは透明アイコンを表示
let icon : JSX.Element = (
<FontAwesomeIcon className='water' icon={faWater} />
);
// もし選択されたマスなら
if(selected){
// ターゲットの有無で表示するアイコンを変更
icon = isTarget? (
<FontAwesomeIcon className='icon certificate' icon={faCertificate} />
) : (
<FontAwesomeIcon className='icon times' icon={faTimes} />
)
}
cells.push(
<button className={`cell ${selected? 'selected' : 'unselected'} ${isFinished && isTarget? 'target' : ''}`}
key={`${i.toString()}_${j.toString()}`}
onClick={()=>{this.onClickCell(i, j)}}>
{icon}
</button>
)
}
rows.push(
<div className="row" key={`${i.toString()}`}>
{cells}
</div>
)
}
const result : JSX.Element = isFinished? (
<div className="status inset">
{targets.filter(t => !t.isBroken).length === 0? (
<p className='result'>完全勝利!</p>
) : (
<p className='result'>残念!また遊んでね</p>
)}
</div>
) : null;
return (
<div>
<div className="description inset">
<p>下記のマスの中に隠れている海賊船を大砲で撃沈しよう!<br/>
海賊船は3隻で、船体の長さはそれぞれ3マス、4マス、5マス。<br/>
大砲の弾は最大24発まで発射できます。</p>
</div>
{result}
<div className="status inset">
<div>
<FontAwesomeIcon className='icon certificate' icon={faCertificate} />
<p>当たり</p>
</div>
<div>
<FontAwesomeIcon className='icon times' icon={faTimes} />
<p>外れ</p>
</div>
<div>
<FontAwesomeIcon className='icon ship' icon={faShip} />
<p>{`${targets.filter(t => !t.isBroken).length}/${targets.length}`}</p>
</div>
<div>
<FontAwesomeIcon className='icon crosshairs' icon={faCrosshairs} />
<p>{`${max - selectedCell.length}/${max}`}</p>
</div>
</div>
<div>
{rows}
</div>
<div className="menu">
<button className="neumorphic-btn" onClick={this?.reset} >リセット</button>
</div>
</div>
);
}
}
export default GameBoard;
※駆け足で作ったので、冗長&汚い箇所がありますが、ver1
として掲載。
要素技術紹介
Typescript
言わずと知れたAltJS
の筆頭株。
混沌としたJavascript
に秩序と安寧をもたらすとか、もたらさないとか。
この規模のアプリならJavascript
でも全然いけるのだが、より大規模なアプリケーション開発になると静的型付け8が欲しくなってくる。
React
今をときめくJavascript
のフレームワーク。
Vue
やらAngular
やらと宗教戦争を繰り広げている。
HTML
とJavascript
がフュージョンしたようなJSX
や状態管理のフレームワークであるRedux
を用いて多くの初学者を苦しめる9。
ちなみにTypescript
搭載のReact
プロジェクトを作るには下記の通りにする。
参考:create-react-appで React + Typescript な環境を構築する
create-react-app hoge --typescript
Neummorphism(ニューモフィズム)
これからのトレンドになるとかならないとか言われているUI
デザイン。
初期のiOS
でちらほら見受けられたSkeuomorphism
(スキューモフィズム)と現在幅を聞かせているマテリアルデザインのいいとこ取りをしたような見た目。
その性質上、影を自在に操る能力者10だけが使いこなせるとされる。
参考:ニューモーフィズム?CSSコピペ実装できる新Webトレンドの参考HTMLスニペット、ツールまとめ
GitHub Pages
静的なWeb
ページなら何のコストもかけずに公開できる。
学生の時分は、手塩にかけて育てたHTML
をローカル端末のブラウザで開いて「自分で作った物が動いた!」と大騒ぎしていたが、今では公開まで手軽にできる。
ちなみにcreate-react-app
で作ったアプリを公開する場合は、package.json
に下記を追加してnpm run build
を実行してpush
するだけである(要GitHub
側設定)。
※わざわざdocs
ディレクトリを作っているのは、GitHub Pages
がmaster
ブランチのdocs
を対象とする制約があるため。
{
"scripts": {
"build": "react-scripts build && mv build docs"
},
"homepage": "https://【GitHubユーザ名】.github.io/【リポジトリ名】"
}
npm run build
まとめ
所要時間だが、それなりに見た目を整えて公開するまでだいたい3,4時間といったところだった。
過去の自分の作品を作り直してみると、自身の成長度合いを知ることができ、非常に有意義な時間だったといえる。
ロジック的には大したものではないが、当時の自分が逆立ちしても解決できなかったようなバグをあっさりと解消できたのはなかなかに痛快だった。
また、デザインもなるべく今の時代に即した形にしているため、何年か経った後に同じ物を時々のデザインで作ると、デザイントレンドの変遷が視覚化できて面白いのではないかと思った。
-
これがまた見返してみると酷いソースだった。忘年会後に「稀に-1番が抽選される」というビンゴの常識を覆すバグを含んでいたことが発覚したのは記憶に新しい。 ↩
-
厳密には「プログラミング基礎」という講義を受講していた。最初こそ簡単だと思っていたが、「オブジェクト思考」云々のくだりで考えることを止めた。「車クラスを継承した救急車クラス?走れればなんでもええやん」という安易な思考停止は、数年越しに自分を苦しめることとなる。 ↩
-
教本片手に
UPDATE
文を実行すると、目の前が真っ白になった。結果的に全データの削除フラグを立ててしまっただけで事なきを得たのだが、周りの私を見る目も真っ白になった。 ↩ -
思い立ったというか、この時は
Web
以外のクラサバやスマホアプリ等々のシステムを知らなかった。世の中知らない事だらけである。 ↩ -
独特のグラフィックと広大なフィールド、さらにニテン堂等の奥深いやり込み要素に当時少年だった私は度肝を抜かれた。そして予約特典の「時のオカリナ裏」のあまりの難易度に更に度肝を抜かれた。 ↩
-
今にして思うと、ニュアンスから察するに教授が課した「言語」とは
Java
やらC#
やらのことだったと思うのだが、きっと違ったのだろう。 ↩ -
正確には「敵艦隊」。
GC
版では「敵艦隊」だが、リマスターされたWii U
版では「巨大イカ」に変更されている。背景には倫理的な理由があったのかもしれないが、イカ相手に大砲をぶっ放すのは動物愛護的にはどうなのか。 ↩ -
Javascript
で何かしらを作った事がある人は「number
だと思って扱っていた変数がいつの間にかstring
になっていた」という経験をしたことがあると思う。酷い時にはundefined
になっていて、原因もとい犯人探しに奔走することも数知れず。そんな理不尽な出来事も無問題。そうTypescript
ならね。 ↩ -
一度覚えてしまえば何てことはないのだが、覚え始めの頃は古代文明の碑文のように思えた。「
Store
とState
とReducer
がいて、Action
をDispatch
すればいいんだよ」というルー大柴も裸足で逃げ出すような説明をされればそう思うのも当然か。 ↩ -
今回の作品開発にあたって
box-shadow
をしっかり勉強することになったが、未だにリファレンスを見ながらでないと実装がおぼつかない。これをサラッとできるってんだからデザイナーさんってすげえや。 ↩