Edited at

【2019年4月版】JavaScriptのconsoleでスネークゲームをワンライナー!


元ネタ

先週の記事「JavaScriptのconsoleがすごいことになってた。」に触発されて弊社の @prononami 氏がコンソール専用のスネークゲームを作ってくれました。

でもやっぱりスネークゲームといえばN88-BASICの時からワンライナーに挑戦してみるってのが定番ですよね?!

そんなわけでスネークゲームワンライナーに挑戦してみました!


リファクタリング、その1

元の実装はこちらをご覧いただくとしてさっそくガシガシ、リファクタリングしていきたいと思います。

方針としては、


  1. 一度しか使用していない変数・メソッドの除去

  2. 複数回使用している変数・メソッドは名前を短く

  3. マップ、リストで置き換えられる変換メソッドはマップ・リスト化

  4. 結果が固定できるものは先に固定

  5. できるだけif?:演算子に置換

とりあえずこんな感じでロジックはそのまま(?)でコードを圧縮した結果が以下です。

const c = console,

mt = Math,
ar = Array,
// game info
w = 20,
h = 16,
br = JSON.stringify(ar.from(new ar(h), () => new ar(w).fill(0))),
mp = ar.from(new ar(h), () => [...new ar(w).fill('%c '), '%c\n']).join(''),
// 枠の外か判定
gg = p => p[1] < 0 || p[1] >= w || p[0] < 0 || p[0] >= h,
// エサをランダムな位置に出現させる
sf = () => [mt.floor(mt.random() * h), mt.floor(mt.random() * w)],
// 座標用配列の同一判定
eq = (a1, a2) => a1[0] === a2[0] && a1[1] === a2[1],
d = document,
stBtn = d.getElementById('start'),
//ゲームの開始終了を変更
chgGmSt = flag => {
isPlay = flag;
stBtn.disabled = flag;
},
css_b =
'padding-left:20px;padding-top:10px;line-height:10px;font-size:0px;background-color:',
// draw Map to Log
drawMap = f => {
css = [];
f.forEach(l => {
css = css.concat([
...l.map(e => css_b + { 0: 'black;', 1: 'yellow;', 2: 'red;' }[e]),
''
]);
});
c.log(mp, ...css);
};
var mVec = [0, 0],
// control game
isPlay = false;
//キー検知
d.onkeydown = event => {
if (!isPlay) return;
mVec = {
ArrowRight: [0, 1],
ArrowLeft: [0, -1],
ArrowUp: [-1, 0],
ArrowDown: [1, 0]
}[event.key];
};
c.log('%cPress Click Start Button', 'font-size:20px;');
// Game start
function onStart() {
// init game
var m = [[10, 8], [10, 9], [10, 10]];
mVec = [0, -1];
chgGmSt(true);
var fd = sf();
var fr = 0;
// game main
var tm;
tm = setInterval(() => {
var f = JSON.parse(br);
const pre = m[0];
// sneakを動かす
m.pop();
m.unshift([pre[0] + mVec[0], pre[1] + mVec[1]]);
const h = m[0];
// sneakの衝突判定
if (gg(h) || m.filter(e => eq(e, h)).length > 1) {
// ゲームオーバー処理&表示
clearInterval(tm);
chgGmSt(false);
c.log('%cGame Over Score: %d', 'font-size:20px;', m.length - 3);
return;
}
// get food
if (eq(h, fd)) {
m.push([h[0], h[1]]);
fd = sf();
}
// sneak全長をフィールドにセット
m.forEach(p => {
f[p[0]][p[1]] = 1;
});
// エサをフィールドにセット
f[fd[0]][fd[1]] = 2;
// ログの数による描画速度低下を防ぐため定期的にリフレッシュ
fr++;
if (fr > 300) {
fr = 0;
console.clear();
}
drawMap(f);
}, 1000 / 3);
}

一度初期化して作った配列を再利用する=ディープコピーを作成するために JSON.stringify を使用しています。

またロジックを見やすくするためにいったん const などを使っています。

functionで定義されているものはメインのonStart以外は()=>を使って短くしています。


リファクタリング、その2

だいぶスリムになりました。すでにがっつり変わってますが。。。また、プログラムの全体像もよくわかるようになってきました。

素のリファクタリングはこれが限界でしょうかね。。。?

ちょっとロジックの組みなおしにチャレンジします。

まずはdrawMapで実行時に CSS の文字列をロジックで書いている箇所、もっと事前に決めておけませんかね・・・?

'padding-left: 20px; padding-top: 10px; line-height: 10px; font-size:0px;background-color: 'の箇所はそのままですし、結局、CSS の文字列とセットで空文字列''を送りたいだけなわけです。

んじゃ、あらかじめ blackにしておきますか?

それと座標の判定、もっと言えば座標値を配列で持ってますが js はタプルとかPointクラスみたいなものがないので数値で保持するメリット薄いんですよね。むしろpushとかunshiftの時、シャローコピーにならないように配列を生成しとかないと副作用があったり手間なんですよね。。一次元配列に直して取り扱ってしまいましょう。。。

ロジックそのもののリファクタリングに着手した結果が以下です。

const c = console,

mt = Math,
ar = Array,
// game info
wd = 20,
ht = 16,
p = (x, y) => y * wd + x,
q = p => [p % wd, mt.floor(p / wd)],
// br = JSON.stringify(ar.from(new ar(h*w), 'black')),
mp = ar
.from(new ar(ht), () => [...new ar(wd).fill('%c '), '%c\n'])
.flat()
.join(''),
// 枠の外か判定
gg = p => p[0] < 0 || p[0] >= wd || p[1] < 0 || p[1] >= ht,
// エサをランダムな位置に出現させる
sf = () => mt.floor(mt.random() * ht * wd),
d = document,
stBtn = d.getElementById('start'),
//ゲームの開始終了を変更
chgGmSt = flag => {
isPlay = flag;
stBtn.disabled = flag;
},
cs =
'padding-left:20px;padding-top:10px;line-height:10px;font-size:0px;background-color:';
var mVec = [0, 0],
// control game
isPlay = false;
//キー検知
d.onkeydown = event => {
if (!isPlay) return;
mVec = {
ArrowRight: [1, 0],
ArrowLeft: [-1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1]
}[event.key];
};
c.log('%cPress Click Start Button', 'font-size:20px;');

// Game start
function onStart() {
// init game
mVec = [0, -1];
var m = [p(10, 8), p(10, 9), p(10, 10)],
fd = sf(),
fr = 0,
// game main
tm;
chgGmSt(true);
tm = setInterval(() => {
// sneakを動かす
const pre = q(m[0]),
t = [pre[0] + mVec[0], pre[1] + mVec[1]],
pt = p(t[0], t[1]);
// sneakの衝突判定
if (gg(t) || m.some(e => e === pt)) {
// ゲームオーバー処理&表示
clearInterval(tm);
chgGmSt(false);
c.log('%cGame Over Score: %d', 'font-size:20px;', m.length - 3);
return;
}
m.pop();
m.unshift(pt);
const h = m[0];
// get food
if (h === fd) {
m.push(h);
fd = sf();
}
// ログの数による描画速度低下を防ぐため定期的にリフレッシュ
fr++;
if (fr > 300) {
fr = 0;
console.clear();
}
// エサをフィールドにセット&sneak全長をフィールドにセット
c.log(
mp,
...ar
.from(new ar(ht * wd), (l, i) =>
i == fd ? 'red' : m.indexOf(i) >= 0 ? 'yellow' : 'black'
)
.map((l, i) => ((i + 1) % wd ? cs + l : [cs + l, '']))
.flat()
);
}, 1000 / 3);
}

うーん、ひどいw

やっぱり長いですね。。。


リファクタリング、その3

これより、さらに短くするためにonStartの引数を利用し汚い手を使って初期設定的なものは以下のように呼び出す引数側に回してしまいましょう。。。

以下がボタンのクリックイベント側。

onclick="onStart(20, 16, [0, -1], [[10, 8], [10, 9], [10, 10]], this, 0)"

以下のようにonStart内で使われる変数は引数に持っていきます。引数だからと言って容赦なくミュータブルに使っています。良い子はまねしないように!

function onStart(wd, ht, mv, m, bt, fr) {

const c = console,
mt = Math,
ar = Array,
p = (x, y) => y * wd + x,
q = p => [p % wd, mt.floor(p / wd)],
mp = ar
.from(new ar(ht), () => [...new ar(wd).fill('%c '), '%c\n'])
.flat()
.join(''),
gg = p => p[0] < 0 || p[0] >= wd || p[1] < 0 || p[1] >= ht,
sf = () => mt.floor(mt.random() * ht * wd),
d = document,
cs =
'padding-left:20px;padding-top:10px;line-height:10px;font-size:0px;background-color:';
d.onkeydown = event => {
mv = {
ArrowRight: [1, 0],
ArrowLeft: [-1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1]
}[event.key];
};
var fd = sf(),
tm;
bt.disabled = true;
m = m.map(e => p(e[0], e[1]));
tm = setInterval(() => {
const pre = q(m[0]),
t = [pre[0] + mv[0], pre[1] + mv[1]],
pt = p(t[0], t[1]);
if (gg(t) || m.some(e => e === pt)) {
clearInterval(tm);
bt.disabled = false;
c.log('%cGame Over Score: %d', 'font-size:20px;', m.length - 3);
return;
}
m.pop();
m.unshift(pt);
const h = m[0];
if (h === fd) {
m.push(h);
fd = sf();
}
if (fr++ % 300 === 0) {
console.clear();
}
c.log(
mp,
...ar
.from(new ar(ht * wd), (l, i) =>
i == fd ? 'red' : m.indexOf(i) >= 0 ? 'yellow' : 'black'
)
.map((l, i) => ((i + 1) % wd ? cs + l : [cs + l, '']))
.flat()
);
}, 1000 / 3);
}

変数まわりもかなりすっきりしましたね?!

しかし、いや~、長いっすね。。。でも、1 行にしてしまいましょう。。。


ワンライナー最終形態

不要なvarconst}の前の;も除去。

あと Math.floorArray.from などのプロトタイプメソッドは this がずれても大丈夫でしょう?短い名前に代入してしまいます。(console.logはインスタンスメソッドなのでthisがズレると危ういのです。)

そんな感じでいろいろ刈り込んでいった結果を元の click イベントハンドラにどーんと載せます。

onclick="((wd,ht,mv,m,bt,fr)=>{c=console,mt=Math,flr=mt.floor,ar=Array,frm=ar.from,p=(x,y)=>y*wd+x,q=p=>[p%wd,flr(p/wd)],fz='font-size:',mp=frm(new ar(ht),()=>[...new ar(wd).fill('%c '),'%c\n']).flat().join(''),sf=()=>flr(mt.random()*ht*wd),d=document,cs='padding-left:20px;padding-top:10px;line-height:10px;'+fz+'0px;background-color:';d.onkeydown=event=>{mv={ArrowRight:[1,0],ArrowLeft:[-1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[event.key]};fd=sf();var tm;bt.disabled=true;m=m.map(e=>p(e[0],e[1]));tm=setInterval(()=>{pr=q(m[0]),t=[pr[0]+mv[0],pr[1]+mv[1]],pt=p(t[0],t[1]);if(t[0]<0||t[0]>=wd||t[1]<0||t[1]>=ht||m.some(e=>e===pt)){clearInterval(tm);bt.disabled=false;c.log('%cGameOverScore:%d',fz+'20px;',m.length-3);return}m.pop();m.unshift(pt);h=m[0];if(h===fd){m.push(h);fd=sf()}if(fr++%300===0){c.clear()}c.log(mp,...frm(new ar(ht*wd),(l,i)=>i===fd?'red':m.indexOf(i)>=0?'yellow':'black;').map((l,i)=>((i+1)%wd?cs+l:[cs+l,''])).flat())},1000/3)})(20,16,[0,-1],[[10,8],[10,9],[10,10]],this,0)"

onclick の中は現在 984 文字ですw


アドバイス頂きました!

アドバイス頂いた点を反映してさらに刈り込んでいきます!

一行にする直前は以下のコードになりました。

((wd, ht, mv, m, bt, fr) => {

c = console,
ar = Array,
p = (x, y) => y * wd + x,
q = p => [p % wd, (p / wd) | 0],
fz = 'font-size:',
mp = ar.from(ar(ht), () => [...ar(wd).fill('%c '), '%c\n'])
.flat()
.join(''),
sf = () => (Math.random() * ht * wd) | 0,
d = document,
cs =
'padding:10px 0 0 20px;line-height:10px;' + fz + '0px;background-color:';
d.onkeydown = event => {
mv = {
ArrowRight: [1, 0],
ArrowLeft: [-1, 0],
ArrowUp: [0, -1],
ArrowDown: [0, 1]
}[event.key];
};
fd = sf();
var tm;
bt.disabled = !0;
m = m.map(e => p(...e));
tm = setInterval(() => {
pr = q(m[0]),
t = [pr[0] + mv[0], pr[1] + mv[1]],
pt = p(...t);
if (t[0] < 0 || t[0] >= wd || t[1] < 0 || t[1] >= ht || m.includes(pt)) {
clearInterval(tm);
bt.disabled = !1;
c.log('%cGameOverScore:%d', fz + '20px;', m.length - 3);
return;
}
m.pop();
m.unshift(pt);
h = m[0];
if (h === fd) {
m.push(h);
fd = sf();
}
if (fr++ % 300 === 0) {
c.clear();
}
c.log(
mp,
...ar.from(ar(ht * wd), (l, i) =>
i === fd ? 'red' : m.includes(i) ? 'yellow' : 'black;'
)
.map((l, i) => ((i + 1) % wd ? cs + l : [cs + l, '']))
.flat()
);
}, 1000 / 3);
})(20, 16, [0, -1], [[10, 8], [10, 9], [10, 10]], this, 0)

上記コードから不要な空白文字と}前のセミコロンを除去して以下のワンライナーになります!

onclick="((wd,ht,mv,m,bt,fr)=>{c=console,ar=Array,p=(x,y)=>y*wd+x,q=p=>[p%wd,(p/wd)|0],fz='font-size:',mp=ar.from(ar(ht),()=>[...ar(wd).fill('%c '),'%c\n']).flat().join(''),sf=()=>(Math.random()*ht*wd)|0,d=document,cs='padding:10px 0 0 20px;line-height:10px;'+fz+'0px;background-color:';d.onkeydown=event=>{mv={ArrowRight:[1,0],ArrowLeft:[-1,0],ArrowUp:[0,-1],ArrowDown:[0,1]}[event.key]};fd=sf();var tm;bt.disabled=!0;m=m.map(e=>p(...e));tm=setInterval(()=>{pr=q(m[0]),t=[pr[0]+mv[0],pr[1]+mv[1]],pt=p(...t);if(t[0]<0||t[0]>=wd||t[1]<0||t[1]>=ht||m.includes(pt)){clearInterval(tm);bt.disabled=!1;c.log('%cGameOverScore:%d',fz+'20px;',m.length-3);return}m.pop();m.unshift(pt);h=m[0];if(h===fd){m.push(h);fd=sf()}if(fr++%300===0){c.clear()}c.log(mp,...ar.from(ar(ht*wd),(l,i)=>i===fd?'red':m.includes(i)?'yellow':'black;').map((l,i)=>((i+1)%wd?cs+l:[cs+l,''])).flat())},1000/3)})(20,16,[0,-1],[[10,8],[10,9],[10,10]],this,0)"

onclick の中は現在 914 文字!70文字も刈り込めました!!

@htsign さんありがとうございます!


さらにアドバイス頂きました!(追記:2019/4/21)

コメントにて追加のアドバイス頂きました!

そしてonclickに渡す直前のコードは以下のようになります。

((wd, ht, mv, m, bt, fr) => {

c = console,
mt = Math,
flr = mt.floor,
ar = Array,
p = (x, y) => y * wd + x,
q = p => [p % wd, flr(p / wd)],
fz = 'font-size:',
mp = ar
.from(ar(ht), () => [...ar(wd).fill('%c '), '%c\n'])
.flat()
.join(''),
sf = () => mt.random() * ht * wd | 0,
d = document,
cs = `padding:10px 0 0 20px;line-height:10px;${fz}0px;background:`,
d.onkeydown = event => {
mv = [[-1, 0], [0, -1], [1, 0], [0, 1]][event.keyCode - 37]
},
fd = sf(),
bt.disabled = 1,
m = m.map(e => p(...e)),
tm = setInterval(() => {
pr = q(m[0]),
t = [pr[0] + mv[0], pr[1] + mv[1]],
pt = p(...t);
if (t[0] < 0 || t[0] >= wd || t[1] < 0 || t[1] >= ht || m.includes(pt)) {
clearInterval(tm);
bt.disabled = 0;
c.log('%cGameOverScore:%d', fz + '20px', m.length - 3);
return
}
m.pop();
m.unshift(pt);
if (pt === fd) {
m.push(pt);
fd = sf();
}
fr++ % 300 === 0 && c.clear();
c.log(
mp,
...ar
.from(ar(ht * wd), (l, i) =>
i === fd ? 'red' : m.includes(i) ? 'yellow' : 'black'
)
.flatMap((l, i) => (i + 1) % wd ? cs + l : [cs + l, ''])
)
}, 1000 / 3))
})(20, 16, [0, -1], [[10, 8], [10, 9], [10, 10]], this, 0)

そして不要な空白文字を削除するとonclickは以下のようになります。

  onclick="((wd,ht,mv,m,bt,fr)=>{c=console,mt=Math,flr=mt.floor,ar=Array,p=(x,y)=>y*wd+x,q=p=>[p%wd,flr(p/wd)],fz='font-size:',mp=ar.from(ar(ht),()=>[...ar(wd).fill('%c '),'%c\n']).flat().join(''),sf=()=>mt.random()*ht*wd|0,d=document,cs=`padding:10px 0 0 20px;line-height:10px;${fz}0px;background:`,d.onkeydown=event=>{mv=[[-1,0],[0,-1],[1,0],[0,1]][event.keyCode-37]},fd=sf(),bt.disabled=1,m=m.map(e=>p(...e)),tm=setInterval(()=>{pr=q(m[0]),t=[pr[0]+mv[0],pr[1]+mv[1]],pt=p(...t);if(t[0]<0||t[0]>=wd||t[1]<0||t[1]>=ht||m.includes(pt)){clearInterval(tm);bt.disabled=0;c.log('%cGameOverScore:%d',fz+'20px',m.length-3);return}m.pop();m.unshift(pt);if(pt===fd){m.push(pt);fd=sf()}fr++%300===0&&c.clear();c.log(mp,...ar.from(ar(ht*wd),(l,i)=>i===fd?'red':m.includes(i)?'yellow':'black').flatMap((l,i)=>(i+1)%wd?cs+l:[cs+l,'']))},1000/3)})(20,16,[0,-1],[[10,8],[10,9],[10,10]],this,0)"

onclickの中は現在、869文字!

@htsign さん、追加ありがとうございます~!


HTML 全体

HTMLは全体として以下のようになっております。

<html>

<header></header>
<body>
F12
<button
id="start"
onclick="((wd,ht,mv,m,bt,fr)=>{c=console,mt=Math,flr=mt.floor,ar=Array,p=(x,y)=>y*wd+x,q=p=>[p%wd,flr(p/wd)],fz='font-size:',mp=ar.from(ar(ht),()=>[...ar(wd).fill('%c '),'%c\n']).flat().join(''),sf=()=>mt.random()*ht*wd|0,d=document,cs=`padding:10px 0 0 20px;line-height:10px;${fz}0px;background:`,d.onkeydown=event=>{mv=[[-1,0],[0,-1],[1,0],[0,1]][event.keyCode-37]},fd=sf(),bt.disabled=1,m=m.map(e=>p(...e)),tm=setInterval(()=>{pr=q(m[0]),t=[pr[0]+mv[0],pr[1]+mv[1]],pt=p(...t);if(t[0]<0||t[0]>=wd||t[1]<0||t[1]>=ht||m.includes(pt)){clearInterval(tm);bt.disabled=0;c.log('%cGameOverScore:%d',fz+'20px',m.length-3);return}m.pop();m.unshift(pt);if(pt===fd){m.push(pt);fd=sf()}fr++%300===0&&c.clear();c.log(mp,...ar.from(ar(ht*wd),(l,i)=>i===fd?'red':m.includes(i)?'yellow':'black').flatMap((l,i)=>(i+1)%wd?cs+l:[cs+l,'']))},1000/3)})(20,16,[0,-1],[[10,8],[10,9],[10,10]],this,0)"
>
start</button
><br />
<br />
操作方法<br />
・ArrowKey: 上下左右の方向転換<br />
注:この画面にフォーカスが当たっていないと操作不可!<br />
<script>
console.log('%cPress Click Start Button', 'font-size:20px;');
</script>
</body>
</html>

初期表示のメッセージはこのままで勘弁してください。。。

1k切ったということでちょっと満足してしまいました。まだ座標とCSSの吐きだしロジックで改善点あると思いますが今夜はこれくらいにしておこうと思いますw

(これ絶対、超絶ロジックがベーマガに載ってたと思う)