年イチでちょっとしたブラウザゲームを作ってます(→ 去年)。今年はそこそこ遊べる可愛いアクションゲームを4KB以内で作ってみました。
🎉宣伝させてください!
— ゆき (@yuneco) February 20, 2022
🐱今年も無事、猫の日ゲームができました!https://t.co/XnDD8AXx4k
今年は可愛さはそのまま、限界までコードを削って4KBにおさめました。PCの方はソース表示して見てみてね pic.twitter.com/j0QqH6iSSn
作ったもの:ブラウザで動く4KBのゆるかわアクション
URL: https://yuneco.github.io/mezashi-4kb/
ソース: https://github.com/yuneco/mezashi-4kb
つまりどういう...コト?
-
index.html
という名前のファイルを作って下のコードをコピペする- コード右上のコピーボタンが便利!
- ブラウザでひらく
- ゲームが動く
- ネットワークにつながってなくても動くよ!
<!DOCTYPE html><html><meta name=viewport content="width=620"><body><script>const r="px",T="style",f="filter",x="forEach",I="innerHTML",y="stroke",B=document,j=B.body,P=t=>B.createElement(t),w=t=>j.appendChild(t),k=(t=1)=>Math.random()*t,O=setTimeout,H=(t,e)=>{t.onpointerdown=o=>{o.cancelBubble=!0,e()}},u="#666",rt="#fff",mt="#0000",C=100,m=(t,e=4,o="none",s=u)=>t.split("*").map((n,a)=>`<path d="M${atob(n).replace(/(.|\s)/g,S=>((a=S.charCodeAt(0))<20?"MmLlHhVvCcSsQqTtAaZz"[a]:a-148)+" ")}"${y}=${s} ${y}-width=${e} fill=${o} ${y}-linecap=round ${y}-linejoin=round />`).join(""),Y=(t,e,o)=>`<svg viewBox="0 0 ${e} ${o}">${t}<\/svg>`,ut=Y(m("zb8LkJ2KmHuRcYgIn7aWw5jEC6WZrp0JkpqQn46kiImJp5OglpWen62eq5SzgrN+CtTGzb8S",0,"#ec6")+m("zqoJlpidqoapAYSeCZOblaabqQunjaeICsXEvsE*xdkJmZmUnZGWAZWsCZWajpuPlA*x/MJlZuPm4+U*wcIJm5Wcj5yPn5+nqauwEZmZlJSVlJkJjKptsFyf*r9EJkpiPno+fAbSGCZmTs5GcpQjI6LfpseIJkZGTjpaM*r98R4eGUlJSKjwmPko+nlqQLnY6ejADItRGVlZSUlJWUCZSUlJOUkwuUlZSVEgGbmAmTlI+VkZcAmsEImr2pw6bICY6VgZODjguaiJuICqrCpsgBtoUHlQGSkweVAZeKkpUBpLeXkQF/nAmZmJSdkZYBnYyboA",2)+m("xp8JkoeOh4ySjYqHioebCKasnrSjvQuioqefCsW7xrAJlpOYkJiLlpmYl5iVC5GMi4sS*psgJk5WJn5KjC5+Ln4kJkpSNk4uQEg*0dIRlZWUlZSVk5WVlJSUk5US",2,u),80,120),K="JjI+DmIyemJaenaCmjpGEjnePloyVg5B9l4SSf4WKkY+Pi42LC5CXjaIInayWwabKCY6ggLuPvpaUmZSckJadoJuikhHr65SUlLSWCNj33vfh8gmVl5aYmZifkqJ+oW2Ygo90hnAS",pt=Y(m(`5as${K}`,0,"#998")+m(`4Ko${K}*o7wJkpeLmYmTAaqKi5iclwGcl52VALa/A5yXALG+A5OYAZKPk5g`),C,C),_="JlJmIn3afiZR4knKPiZt8noOQjIiZh6WSoI2rjLaLopOyl7KdEw",At=Y(m(`8ac${_}`,0,"#9bc")+m(`76U${_}GFkpiYAZSQkJg`),C,40),g=new AudioContext,U=g.createGain(),q=U.gain;U.connect(g.destination);const J=([t,...e])=>{if(t){const o=g.createOscillator();o.connect(U),o.frequency.setValueAtTime(t,g.currentTime),q.value=.3,q.linearRampToValueAtTime(0,g.currentTime+.09),o.start(),O(()=>{o.stop(),J(e)},C)}},A=600,L=A,dt=20,Q=-1,Ct=.003,St=.01,ft=.04,gt=.2;let D,X,tt,M=0,Z=0,G=0,z=0,E=!1,h=0,b=1,d=6,v=[],i=[],l=[],c;const et=t=>{t.border="solid 2px"+u},ot=(t,e)=>{tt[I]=t,D[I]=e},$=(t,e,o,s=0,n=0)=>{t.position="absolute",t.top=n+r,t.left=s+r,t.width=e+r,t.height=o+r},kt=t=>{const e=P("button"),o=e[T],s=n=>o.background=n?u:rt;return $(o,A,60,0,L+10),et(o),o.color=u,o.fontSize=24+r,s(),H(e,()=>{t(),J([392]),s(!0),O(s,C)}),w(e)},nt=(t,e=24,o=0,s="")=>{const n=P("div"),a=n[T];return $(a,A,e,0,o),a.textShadow="0 0 0 "+u,a.textAlign=s,a.fontSize=e+r,a.color=t,w(n)},V=(t,e,o,s=0,n=Q,a=0,S=0)=>{const R=P("i"),W=R[T];$(W,e,o),W.transformOrigin="bottom";const F={e:w(R),x:a,y:S,m:s,v:0,a:n,w:e,h:o};return R[I]=t,v.push(F),F},st=t=>{v[f](e=>!t.includes(e))[x](e=>e.e.remove()),v=t},at=t=>{t.v+=t.a,t.y+=t.v*Z,t.y<=0&&(t.v=t.y=0),t.x+=t.m*Z,t.e[T].transform=`translate(${t.x}px, ${L-t.y-t.h}px) scaleY(${Math.sin(G/7)/20+1})`},Tt=()=>{c.y||(c.v=25)},Jt=()=>{i.push(V(pt,50,50,-4-h*ft,k()<gt?0:Q,A-50,k(300)))},Et=t=>{t.y||(t.v=k(60),t.a=-t.v/20)},yt=()=>{E&&d&&(l.push(V(At,40,10,5,0,50,c.y+40)),d--,d||O(()=>{d=6},2e3),J([784]))},N=t=>t[f](e=>e.x>0&&e.x<A),ct=(t,e)=>t.x<e.x+e.w&&t.x+t.w>e.x&&t.y<e.y+e.h&&t.y+t.h>e.y,xt=(t,e)=>{let o=[];e[x](n=>{const a=t[f](S=>ct(S,n));a.length&&(o=[...o,...a,n])});const s=n=>!o.includes(n);return[t[f](s),e[f](s)]},it=t=>{Z=(t-M)/17,G+=Z;const e=G/dt|0;if(E){b+=Ct,e>z&&k()<b&&(Jt(),b=0),i[x](n=>k()<St&&Et(n)),c.x*=.9,[c,...i,...l][x](at),i=N(i),l=N(l);const o=i.length;[i,l]=xt(i,l);const s=o-i.length;s&&(h+=s,J([523])),i.some(n=>ct(n,c))&<(!0),st([c,...i,...l])}X[I]=`\u{1F431}${h} / `+("\u{1F41F}".repeat(d)||"RELOADING"),z=e,M=t,requestAnimationFrame(it)},It=()=>{i=[],l=[],st([c]),h=0,d=6,E=!0,ot("","JUMP")},lt=t=>{E=!1,ot(t?"GAMEOVER":"Neko Mezashi 4KB","GO!"),c.x=(A-c.w)/2,c.y=L/2,at(c),t&&J([523,466,440,392,349])},p=j[T];et(p);p.fontFamily="arial";p.width=A+r;p.height=L+r;p.position="relative";p.userSelect=p.touchAction="none";H(j,yt);D=kt(()=>(E?Tt:It)());X=nt(mt);tt=nt(u,36,310,"center");c=V(ut,80,C);lt();it(0);</script>
大雑把なレギュレーション
- コンパイル&minify後のJSのサイズが4KB以内になること
- ブラウザの標準機能のみを使い、全て4KBで完結させること。外部のリソース(画像等の素材やCDNで配信されるライブラリ、Web APIなど)は一切利用不可
- ソースコードはASCII文字(英数字と記号)のみ使用可。バイナリとかはダメ
- できるだけ可愛さやゲーム性も頑張ること
今回はこのルールのもとで初代『ネコメザシアタック』相当のゲームを作ることを目標にします。ちなみにこの初代ネコメザシアタックのサイズはフレームワーク(Vue)+プログラム+画像(SVG)+サウンドで300KBくらい、gzip圧縮しても100KBくらいです。
今回これを4KBバイトに詰め込むので、当然ランタイムに入ってくるライブラリやフレームワークは使用しません。ツールはVite + typeScriptです。では早速、4KBに押し込むためのポイントを見ていきましょう。
ポイント1: SVG画像を圧縮する(1.2KB)
それなりのゲームを作ろうとした時に最もデータを食うのがグラフィックです。昔懐かしの7行テトリスは確かにすごいんだけど、見た目が...ね。罫線とテキストだけだとやっぱり可愛くないのでちゃんと画像を使いたいのです。
今回のソースコードだと、上の方、黄色率の高い1.2KBほどがグラフィックの領域です。
限られた容量でグラフィックを表現する方法はいろいろありそうですが、次の3つがメジャーかな、と思います。
- SVGを使ってパスデータを圧縮する
- ドット絵にしてドット数や色数を減らす
- 数式から自動生成する
今回は1のみです。ドット絵も可愛く描ければ味が出ますし、数式からの生成は背景グラやマップの地形なんかに最適ですね。
SVGを圧縮する
SVGはPNGやJPGに比べればコンパクトですが、4KBに収めるにはそれでもちょっと大きすぎです。例えば、このゲームのfaviconに設定しているメザシのアイコン(※4KBには含めてません)は下記の文字列で532バイト。つまり、このままだと4KB全部使っても8メザシも入りません。圧縮しましょう。
▼ faviconに使ったメザシの画像(532byte)
<svg width="410" height="404" viewBox="0 0 100 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(0 20)">
<path fill="#ec7" d="M93.4 18.7c0 5-12 11-30 11-11 0-28-2-34-5-11.3 7-24 10-17-4-8-12 5-13 17-2 12-7 23-8.2 34-9 14-1 30 3 30 9z"/>
<path fill="none" stroke="#666" stroke-linecap="round" stroke-linejoin="round" stroke-miterlimit="10" stroke-width="5" d="M91.4 16.7c0 5-12 11-30 11-11 0-28-2-34-5-11.3 7-24 10-17-4-8-12 5-13 17-2 12-7 23-8.2 34-9 14-1 30 3 30 9zm-15.5-2 4 4m0-4-4 4"/>
</g>
</svg>
ただし圧縮と言ってもよくあるgzipとかは使えません。圧縮したデータを元に戻すためのプログラムも4KBに含まれるので、凝った圧縮アルゴリズムは使えないのです。ブラウザの標準機能と僅かなロジックだけで展開できる圧縮を工夫します↓
▼ 圧縮前のSVGパス例 107バイト
M57.5 21.8c2.2 4.1 9.3 22.1-13.6 20.9m-16.2 9.9c-1.3 7 1.4 18.1 7 21.1s18.5-6.9 19.4-11.6S48.5 47.8 42 44.7
▼ 圧縮後のSVGパス例 39バイト
zqoJlpidqoapAYSeCZOblaabqQunjaeICsXEvsE
SVGのパスは20種のコマンドと座標等の数値を列挙したものです。今回はシンプルに、1つのコマンドまたは座標を1バイトに詰めていきます。このままだと出力がバイナリになってしまうので、最後にbtoaでbase64エンコードをかければ出来上がりです。
結果として、座標として使用できる数値は-118px〜+118px((256 - 20) / 2 = 118
)の整数値のみになります。イラレで118x118pxのアートボードを作り、ここに全ての点(アンカーポイントだけでなくコントロールポイントも)を収めるようにしてお絵描きしています。
展開側のコードはbase64デコードしたデータを逆の手順でコマンドと座標に戻し、パス文字列を組み立てるだけです。繰り返しになりますが、この展開コードは4KBに含まれるので、できる限り無駄がないようコードを詰めていきます。
// 'stroke'という文字列は複数回登場するので定数にする
const STROKE = 'stroke'
// 圧縮したパス文字列から元の<path>要素を組み立てる関数
const str2pathes = (source: string, strokeWidth = 4, fill = 'none', strokeColor = '#666'): string =>
source
.split('*') // 複数のパスの区切りに*を使っているので、最初にパスごとに分割
.map(
(line) =>
`<path d="M${
atob(line) // base64デコード
.split('') // 文字単位に分割
.map( // 各文字を書く文字を元のコマンド又は座標値に戻す
(c, n) => (n = c.charCodeAt(0)) < 20 ? 'MmLlHhVvCcSsQqTtAaZz'[n]: n - 148 // -20 -128
)
.join(' ') // スペースで連結してd属性にセット
}" ${STROKE}=${strokeColor} ${STROKE}-width=${strokeWidth} fill=${fill} ${STROKE}-linecap=round ${STROKE}-linejoin=round />`
)
.join('')
// 複数のパスの<path>要素の文字列をまとめ展開する
const paths = str2pathes(
'zqoJlpidqoapAYSeCZOblaabqQunjaeICsXEvsE*xdkJmZmUnZGWAZWsCZWajpuPlA*x/MJlZuPm4+U*wcIJm5Wcj5yPn5+nqauwEZmZlJSVlJkJjKptsFyf*r9EJkpiPno+fAbSGCZmTs5GcpQjI6LfpseIJkZGTjpaM*r98R4eGUlJSKjwmPko+nlqQLnY6ejADItRGVlZSUlJWUCZSUlJOUkwuUlZSVEgGbmAmTlI+VkZcAmsEImr2pw6bICY6VgZODjguaiJuICqrCpsgBtoUHlQGSkweVAZeKkpUBpLeXkQF/nAmZmJSdkZYBnYyboA',
2
)
ちょっと読みづらいですが、やってることは簡単ですね。base64は圧縮に使うエンコードとしては効率が悪いのですが、何よりatob関数一発でデコードできるのが素敵です。名前も短いので今回みたいなケースでは最高ですね。
ポイント2: 効果音を鳴らす(0.3KB)
グラフィックの次に悩ましいのがサウンドです。BGMとまではいかなくても、最低限の音は鳴らしたいところです。画像で大分容量を喰ってしまったので、サウンドは300バイトほどで最低限の機能を実装します。
ブラウザにはWeb Audio APIがあるので、ちょっとしたSEならこれで波形を生成して簡単に鳴らせます。
const ctx: AudioContext = new AudioContext()
const gainNode: GainNode = ctx.createGain()
const gain = gainNode.gain
gainNode.connect(ctx.destination);
/**
* 指定した音を再生します
* @param hz 音の高さ
* @param durMs 各音の再生時間(ms)
* @param volume 再生音量
*/
export const playNote = (hz: number) => {
if (hz) {
const oscillator = ctx.createOscillator();
oscillator.connect(gainNode);
oscillator.frequency.setValueAtTime(hz, ctx.currentTime);
gain.value = 0.3;
gain.linearRampToValueAtTime(0, ctx.currentTime + 0.09);
oscillator.start();
setTimeout(() => {
oscillator.stop()
}, 300);
}
};
指定の高さの音を0.3秒鳴らして止めるだけ...なんですが、結構長いですね。特にlinearRampToValueAtTime
。この子は音を減衰させるための機能です。関数名が長いので正直使いたくないのですが、これを省いてしまうと完全に機械のビープ音、って感じの音になってしまうので身を削る思いで足します
音を連続再生する
ここまでのコードだと音符一個の再生しかできないので、もうちょっと頑張って音の連続再生ができるようにします。ゲームではゲームオーバーした時の「シラソファミ」みたいな効果音の部分です。
/**
- * 指定した音を再生します
+ * 指定した音を連続して再生します
- * @param hz 音の高さ
+ * @param param0 再生する音の周波数配列
* @param durMs 各音の再生時間(ms)
* @param volume 再生音量
*/
- export const playNote = (hz: number) => {
+ export const playNotes = ([hz, ...rest]: number[]) => {
if (hz) {
const oscillator = ctx.createOscillator();
oscillator.connect(gainNode);
oscillator.frequency.setValueAtTime(hz, ctx.currentTime);
gain.value = 0.3;
gain.linearRampToValueAtTime(0, ctx.currentTime + 0.09);
oscillator.start();
setTimeout(() => {
oscillator.stop()
+ playNotes(rest) // 残りの音を再生
}, 300);
}
};
再生する音を配列で受け取り、一個ずつ再帰的に再生していきます。音の長さを変えられないのでできることは限られますが、僅かな追加で実現できるのでお得ですね
ポイント3:スコアと残弾数をアイコンで表示する(84byte)
ものすごい細かい部分なのですが、ステータス表示の部分も面白いので紹介しておきます。
ゲームのクオリティ的にはこういうところもアイコン的な絵を使っていきたいところですが、当然画像をつかえばそれだけサイズが嵩んでいきます。もちろんFont Awesomeなんて論外です。このジレンマを解決するために絵文字を使います。
// スコアと弾数の表示を更新
stateText[INNERHTML] = `🐱${score} / ` + ('🐟'.repeat(bulletLeft) || 'RELOADING')
絵文字の🐱はアスキーで表現すると\u{1F431}
となり、これだけで9バイトも使ってしまうのですが、それでも画像を使うよりは遥かにローコストです。最後にこれをCSSで塗りつぶして単色にします。
// textShadowを使って絵文字をシルエットで表示する
style.textShadow = '0 0 0 #666'
このテク自体はコード圧縮に限らず、お手軽にアイコンを使いたい時にも有用です1
ポイント4:ゲームのメインロジック(0.4KB)
ぼちぼちゲームのメインロジックも見ていきましょう。ゲームのメインロジックはrequestAnimationFrame
でループを回し、基本的にこのループの中に全部の処理をぶち込みます。パフォーマンス的にはあまりよくない方法ですが、四の五の言ってる余裕はありません。気にせず全部突っ込みましょう。
とりあえずメインループのコード全部載せときます(読まなくてもいいです)
/** フレームごとの処理 */
const tick = (time: number) => {
// 60FPSを1フレームの基準として、前回から何フレーム分時間が経過しているか
frameDelay = (time - lastTick) / 17
frameCount += frameDelay
// 一定フレーム数ごとに「キーフレーム」を設ける
const keyFrameIndex = frameCount / KEY_FRAME_INTERVAL | 0
if (isPlaying) {
// 猫追加判定
// 時間と共に猫出現率を上げていく
catAppearRate += CAT_APPEAR_RATE_INCREASE
// キーフレームのタイミングで乱数が出現率を上回ったら猫を追加
if (keyFrameIndex > lastKeyFrame && random() < catAppearRate) {
addCat()
// 出現率をゼロリセット
catAppearRate = 0
}
// ランダムに猫ジャンプ
cats[FOREACH]((cat) => random() < CAT_JUMP_RATE && catJump(cat))
// たまさんの横位置を定位置にアニメーション(タイトル画面では中央にいるので左端に戻す)
tama.x *= 0.9
// キャラの位置を更新
;[tama, ...cats, ...mzses][FOREACH](updatePos)
// ステージ外に出たキャラを除去
cats = filteroutStageoutCharactors(cats)
mzses = filteroutStageoutCharactors(mzses)
// 衝突判定
const catCount = cats.length
;[cats, mzses] = filteroutHitCharactors(cats, mzses)
// スコア加算
const hitCount = catCount - cats.length
if (hitCount) {
score += hitCount
playNotes([523])
}
// ゲームオーバー判定
if (cats.some((cat) => intersected(cat, tama))) endGame(true)
// 除去されたキャラをDOMからも削除
removeInvalidCharas([tama, ...cats, ...mzses])
}
// スコアと弾数の表示を更新
stateText[INNERHTML] = `🐱${score} / ` + ('🐟'.repeat(bulletLeft) || 'RELOADING')
lastKeyFrame = keyFrameIndex
lastTick = time
requestAnimationFrame(tick)
}
// フレームアニメーションを開始
tick(0)
一見結構長くて普通のコードですが、最適化されやすい書き方を工夫しているので、ビルドすると大体380バイトくらいまで圧縮されます。ループの中身は、基本的にフレームごとに以下の処理を行うだけです:
- 出現率に基づいてネコを追加・ジャンプさせる
- キャラ(たまさん・ネコ・メザシ)の位置を更新
- ステージ外に出たキャラを削除
- ネコとメザシの当たり判定を行い、スコアに反映
- たまさんとネコの当たり判定を行い、当たったらゲームオーバー
当たり判定を実装する
当たり判定はちゃんと実装しようとすると難しい部分で、初代のネコメザシアタックでも外部ライブラリに頼っていました。今回は性能は完全無視して総当たりで矩形が重なっているかをチェックしています。コード的には↓これだけです:
/** 2つのキャラの衝突が衝突するか? */
const intersected = (c1: Chara, c2: Chara) =>
c1.x<(c2.x+c2.w) && (c1.x+c1.w)>c2.x &&
c1.y<(c2.y+c2.h) && (c1.y+c1.h)>c2.y
これを使ってネコのいずれかがたまさんと衝突したらゲームオーバーと判定します:
// ゲームオーバー判定
if (cats.some((cat) => intersected(cat, tama))) endGame(true)
割り切ってしまえば案外簡単ですね
コード圧縮(minify)の共通テク
最後にコードをいい感じに圧縮するための共通的なテクニックを紹介します。とはいえ私はコードゴルフ界隈ではど素人レベルなのでご参考まで。お手柔らかにお願いします
今回のコードはViteでビルドする際に標準のesbuildの機能で圧縮(minify)を行なっています。圧縮の設定はbuild.minifyで調整できますが、軽く触ってみた感じ調整してもさほど効率化はできなかったので完全にデフォルト設定のままです。
その1. コードサイズに一切影響しないもの
まず最初にコードのサイズに一切影響しないものを押さえておきましょう。これは基本的にはコメント・空白とTypeScriptの型まわりだけです。
逆にいえば、TypeScriptの型だけはどれだけ長い名前のものをいくつ作ってもサイズには影響しません。可読性と保守性をできる限り担保するためにも型はしっかり使っていきたいですね。
その2. minifyでいい感じに圧縮してくれるもの
esbuildの圧縮はかなり優秀なので、実際のところ何も考えなくてもかなりの部分はいい感じに小さくなります。例えば、以下の名前は自動で1〜2文字にリネームされます:
- 関数名
- 変数名
- 関数の引数名
つまり、関数や変数の名前は分かり易く長い名前をつけてOKです。
その3. 自分でなんとかしないといけないもの
ここからは圧縮を効かせるために自分でなんとかしないといけない部分です。
例えば、setTimeout
関数の関数名は圧縮しても変わりません。setTimeout
関数を使うたびに10バイト消費するのは辛いので、下記のように変数に代入してから使うようにします。
const timeout = setTimeout
timeout(...)
timeout(...)
timeout(...)
ビルドするとこんな感じですね。変数の初期化にコストがかかりますが、使えば使うほどお得になります。
const T=setTimeout
T(...)
T(...)
T(...)
この書き換えは厳密には等価ではないのですが、今回のケースでは問題ないので無視します。2
配列処理のようなよく使う関数も似た方法で圧縮できます。例えば、'filter'
と'forEach'
を文字列定数にしておくと...
const FILTER = 'filter'
const FOREACH = 'forEach'
こんな書き方ができるようになります:
charas[FILTER]((chara) => !alives.includes(chara))[FOREACH]((chara) =>
chara.e.remove()
)
圧縮後はこうなります:
v[f]((e) => !t.includes(e))[x]((e) => e.e.remove())
.filter
が[f]
で済んで4バイト節約できました。そこまで可読性も損なわれないので、利用頻度の高い関数であればやる価値はあるかと思います。
その4. どうにもならないもの
基本的に圧縮ができないものもあります。代表的なものが「オブジェクトのプロパティ」でしょうか...。変数名や関数名と異なり、オブジェクトのプロパティは圧縮しても名前を短くできません。たとえば↓こんなオブジェクトを作ったとして...
const longlongCat = {
longlongName: 'たまさん'
}
圧縮後はこうです:
const l={longlongName:'たまさん'}
変数名のlonglongCat
はl
に置換されましたが、プロパティ名のlonglongName
は変わりません。JavaScriptではやろうと思えば↓みたいな変態的なプロパティアクセスができてしまうので、勝手に置き換えちゃうと動作の保証ができなくなっちゃうんですね。。
longlongCat['long'.repeat(2)+'Cat']
// longlongCat.longlongName と同じ
ちなみに、クラスも基本はただのオブジェクトなので、クラスを作ってしまうとその中のメソッド名は圧縮されません。たまに「クラスを使うとビルドのサイズが大きくなる」って言われることがありますが、この辺りが理由です。3
私は別に関数型ライクなプログラミングスタイルを推しているわけではないのですが、圧縮命のシチュエーションではクラススタイルは避けたほうがよさそうです。
オチ
最後にちょっとした後日談を...
このゲームはリリース時点では「手動で最適化してなんとか4KB」だったのですが、クリエイティブコーディングの神であるp01氏の降臨によりそこからさらに231バイトも削減できてしまいました。。マジか...
ちなみにご本人曰く「今回はオリジナルのコードを尊重する形で最適化したけど、ゼロから書けば多分2KBくらいで同じのできるよ」とのこと。眩しすぎる...
まとめ
というわけで、今年は「そこそこ遊べる可愛いゲームを4KBで作る」チャレンジでした。4KBのゲーム制作はゴリゴリのコードゴルフやデモと比べてそこそこゆるくチャレンジできるので、ちょっとゲームを作ってみたいweb初学者にもおすすめです。
気になった方は是非挑戦してみてくださいね