🍣

シャリになるときが来た

2024/12/15に公開

祝辞

クソアプリアドベントカレンダー10周年おめでとうございます🎉🎉🎉🎉🎉🎉🎉
毎年、「見ててくださいよ。本物のクソアプリってものを見せてやりますよ。」と言ってたけど、(去年言ってたっけ?)つよつよが多すぎて、謙遜して作ったものを見せたくなるよね。
分かる。

概要

寿司、それは日本人のソウルフード...
寿司、それは手軽に海鮮を食する手段...
寿司、それは、日本の宝...!

しかし、人類は、寿司をネタにしすぎた...
寿司で笑いを取りすぎたのだ。
ウィーラブ 寿司。 ラブアンドピース...(スゥーシ、スゥーシ、スゥーシ)
ネタにされ続けた寿司はお怒りである...
この怒りを沈めるには...

我々が、シャリになるしかない。
人類がシャリになるときが来たのだ...
シャリとして、偉大なる寿司の身許へ参るのだ...!(※本人は0%本気です)

閑話休題

はじめましての方ははじめまして、ご存知の方はそうだよヒズミさんだよ。
今回の記事は、クソアプリアドベントカレンダー15日目の記事だよ。

今回のアプリはこちら!デデドン!!
https://shari-face-detector.vercel.app/
シャリ顔判定機

WebTaleKit(後述)とTypeScriptを用いて、開発したシャリ顔判定アプリデス!

シャリ顔とは?

こ っ ち が 聞 き た い。
Claude3に、「寿司をテーマにクソアプリのアイデア教えて」と聞いたら、候補の中にあったんだよ。

ブロンプト:「寿司をテーマで(生産性のない役立たないジョークアプリの)アイデアを」

 寿司をテーマにした役に立たないアプリのアイデアを考えてみましょう!

1. 「今日のあなたは◯◯巻き」診断アプリ
- その日の行動や気分を入力すると、適当な巻き寿司に例えてくれる
- 「今日のあなたは、具が飛び出しそうなカリフォルニアロール。はみ出る個性を大切に!」
- 「本日は中トロ。値段は高いけど、実力は認められています」
- たまに「おでん」など寿司じゃないものも出てくる

1. 「シャリ男度」測定アプリ
- カメラで顔を撮ると、その人の「シャリ度」を測定
- 「あなたのシャリ度:65%。まだまだシャリっとしてます」
- 「シャリ度が低いです。もっと白くなりましょう」
- 「完璧なシャリ顔!酢飯マスターの称号を授与します」

~以下略~

これらのアプリに実用性は皆無ですが、寿司ファンの心をくすぐるネタになりそうですね!何か特に面白そうなアイデアはありましたか?

詳しく聞くとどうやら白くて、ツヤがあり、丸顔...?らしい。

プロンプト:「そもそも、シャリ顔って何?」

シャリ顔(シャリガオ)というコンセプトを考えてみましょう!

ユーモアを交えて定義すると:

1. 見た目の特徴
- 白くてツヤのある肌
- 丸みを帯びた形
- しっとりとした質感
- 適度な張り
- 柔らかな印象

1. 性格的な特徴
- 温和で穏やか
- 清潔感がある
- 控えめながら芯がある
- 和の心を持っている
- 適度な酸味(wit)がある

1. 理想的なシャリ顔の条件
- 寿司飯のような上品な輝き
- 酢飯のようなほど良い締まり具合
- お米のような優しい丸み
- 板前のような凛とした佇まい
- 玄米とは対極的な洗練された雰囲気

これは完全なジョークですが、「シャリ」の持つ「白い」「つややか」「上品」といった特徴を、人の顔に当てはめた想像的な概念として捉えることができます。

寿司ネタではなく、シャリになりきることこそが真の「シャリ男」への道かもしれませんね(笑)

まぁ、ジョークアプリなので。深く考えずに。
難易度も高くもなく、客観的に何すればいいか分かりやすい...そして、頭がおかしい提案なので、採用した。私がそう決めた。あれは〇ンダムだ。

使い方

灰色のダミー画像をタップします。
カメラから撮る(スマートフォンのみ)か撮影済みの画像ファイルを使うか選択肢が出るので、それを選びます。
画像を選択

画像が読み込まれたら、判定開始。(画像はローカルで読み込まれ、どこにも送信されません)
後は、写真のシャリ顔度合いが結果として、テキストとプログレスバーで表示されます。
結果発表

結果はX(旧: Twitter)に投稿ができます。
その後の選択肢

さぁ、君は何点かなぁ!?

ちなみに、選択肢と説明文の表示、背景画像の変更は、WebTaleKitの機能を使って、実現しています。

判定方法

新規開発の判定エンジンは、古来より伝わる「シャリの極意」を元に...
某有名寿司チェーン店の職人さんから伝授された秘伝の技を、現代のテクノロジーで再現したものなんです。

判定の詳細については、申し訳ありませんが企業秘密となっております。
(実は毎回違う結果が出るのは、シャリの神様の気分次第とも言われています...)

ただし、時として松崎し○るさんが「海苔」と判定されたり、
回転寿司のシャリが「グランドマスター」と判定されたりする謎の現象も...
これはきっと、シャリの神様の遊び心なのでしょう。

シャリの道は深い...(けっして、ランダムな値が出ているわけじゃないんだからね!勘違いしないでよね!)

処理の流れ

画像をファイル選択ダイアログかまたはカメラで撮影した取得し、真ん中のIMG要素に表示。
約3秒後に、ランダムに0~100までのスコアを表示し、それに応じた結果のコメントとアドバイスを表示します。
結果のコメントは、各スコア層で4種類ずつ、
アドバイスは、5種類の中から表示されます。
結果発表の後、メッセージを進めると、もう一回判定とXに投稿の選択肢が出ます。

みんなで、遊んだ結果をXで投稿して、この記事をバズらせてくれ!!!

なお、画像・カメラでの撮影画像の取得、判定処理、Xへの投稿、デバイス種類の判定はTypeScript
画面表示と選択肢の表示、背景の画像変更は、webTaleKitが担当しています。

技術構成

フロントエンドで完結するアプリです。

言語:WebTaleScript + TypeScript
フレームワーク(?):ビジュアルノベルゲームエンジン WebTaleKit v0.2.9(アルファ版)

WebTaleKitは、HTMLライクな独自言語WebTaleScriptとJavaScript(又はTypeScript)を用いて、Webブラウザで動作するビジュアルノベルゲームが作れるゲームエンジンです。
ゲームシナリオをシーン単位で管理し、その制御をWebTaleScriptで記述します。
機能をJavaScript(又はTypeScript)で拡張することも可能です。
画面は独立したHTMLで作成ができます。

簡易的なルーティング機能やREST API通信機能を備えているので、Webアプリも作ろうと思えば、作れます。

わざわざ、自作のノベルゲームエンジンを採用した理由は、エゴではありますが、Webアプリとして、動く以上、ほかにも活用が可能だろうと思い、宣伝も兼ねて、ノベルゲームに限らない利用方法に挑戦するためです。

https://github.com/EndoHizumi/webTaleKit

npm create コマンド対応なので、コマンド一行↓↓で、環境構築が完了します。
npm create tale-game

今回のアプリのソースコードは以下のようになって居ます。
HTMLを書くように、各行をセマンティックに記述できます。

ソースコード全文
<scene name="title">
    <scenario>
        <show mode="bg" src="./src/resource/background/title_bg3.png" />
        <if condition="getDevice() == 'mobile'">
            <then>
                <choice prompt="どちらで判定しますか?">
                    <item label="判定を開始する(カメラ撮影)">
                        <call method="getPicture(0)" />
                    </item>
                    <item label="判定を開始する(画像から)">
                        <call method="getPicture(1)" />
                        <text>画像を選んだら、タップしてください</text>
                    </item>
                </choice>
            </then>
            <else>
                <choice prompt="">
                    <item label="判定を開始する(画像から)">
                        <call method="getPicture(1)" />
                        <text>画像を選んだら、タップしてください</text>
                    </item>
                </choice>
            </else>
        </if>
        <wait />
        <show mode="bg" src="./src/resource/background/title_bg4.png" />
        <text time="1000">測定中.</text>
        <text time="1000">測定中..</text>
        <text time="1000">測定中...</text>
        <show mode="bg" src="./src/resource/background/title_bg5.png" />
        <call method="clearText()" />
        <call method="showResult()" />
        <wait />
        <choice>
            <item label="もう一度判定する">
                <call method="clear()" />
                <jump index="0" />
            </item>
            <item label="Xに投稿する">
                <call method="shareToX(result)" />
                <call method="clear()" />
                <text>Xへ投稿ありがとう</text>
            </item>
        </choice>
        <wait />
        <choice>
            <item label="もう一度判定する">
                <call method="clear()" />
                <jump index="0" />
            </item>
        </choice>
    </scenario>
    <script type="text/typescript">
       let api: any = null;

       export const sceneConfig = {
           name: 'title',
           background: './src/resource/background/title_bg.png',
           bgm: './src/resource/bgm/ichi.mp3',
           template: './src/screen/title.html'
       }

       export let result = {
           score: 0,
           message: '',
           advice: ''
       }

       // 診断メッセージデータ
       export const shariMessages = {
           perfect: [
               "完璧なシャリ顔!寿司職人からのスカウトに注意です!",
               "あなたを見ると酢飯が炊きたくなります!",
               "これぞ正真正銘のシャリ男!",
               "シャリ界の次世代エース誕生!"
           ],
           high: [
               "かなりのシャリ度!あとちょっとで極上です",
               "シャリっとした雰囲気、素晴らしい!",
               "このシャリ感、惚れ惚れします!",
               "シャリ修行の成果が出ています!"
           ],
           medium: [
               "まずまずのシャリ具合。精進あるのみ!",
               "シャリとしては及第点です",
               "シャリの道も一歩一歩",
               "シャリ修行の途中かな?"
           ],
           low: [
               "シャリ度が物足らない...精米しましょう",
               "もっと白くなれる可能性を秘めています",
               "シャリの道はまだまだ長いです",
               "シャリ修行が必要かもしれません..."
           ],
           critical: [
               "あなた...もしかして玄米派?",
               "シャリの概念がありません",
               "酢飯を見直すところから始めましょう",
               "シャリ度緊急事態発生!"
           ]
       }

       const advices = [
           "白米をもっと食べましょう",
           "寿司屋で修行を積むことをおすすめします",
           "日光に当たりすぎると玄米化するので注意",
           "シャリ度を上げるには握り寿司を週3回",
           "酢飯マイスターの称号まであと一歩!"
       ];

       // シーン初期化処理
       export function init(instance: any) {
           // WebTaleKitのインスタンスを取得
           api = instance;
       }

       export function clearText() {
           // テキストクリア処理
           api.drawer.clearText();
       }

       export function clear() {
           // 画像クリア処理
           (document.querySelector('.facePicture') as HTMLImageElement).setAttribute("src", "./src/resource/background/300x200.png");
           (document.querySelector('.result') as HTMLElement).style.visibility = 'hidden';
           (document.querySelector('progress') as HTMLElement).setAttribute("value", String(0));
           (document.querySelector('.scoreText') as HTMLElement).innerHTML = `シャリ度:000`;
           clearText();
       }

       export function showResult() {
           // 結果表示処理
           result = judge();
           (document.querySelector('.result') as HTMLElement).style.visibility = 'visible';
           (document.querySelector('progress') as HTMLElement).setAttribute("value", result.score.toString());
           (document.querySelector('.scoreText') as HTMLElement).innerHTML = `シャリ度:${result.score}`;
           (document.querySelector('.title') as HTMLElement).innerHTML = '判定結果';
           (document.querySelector('.resultMessage') as HTMLElement).innerHTML = result.message;
           (document.querySelector('.advice') as HTMLElement).innerHTML = `アドバイス: ${result.advice}`;
       }

       export function judge() {
           let messageArray = [];
           const score = Math.floor(Math.random() * 101)
           // スコアに応じてメッセージを選択
           if (score >= 90) messageArray = shariMessages.perfect
           else if (score >= 70) messageArray = shariMessages.high
           else if (score >= 50) messageArray = shariMessages.medium
           else if (score >= 30) messageArray = shariMessages.low
           else messageArray = shariMessages.critical
           result = {
               score: score,
               message: messageArray[Math.floor(Math.random() * messageArray.length)],
               advice: advices[Math.floor(Math.random() * advices.length)]
           }
           return result;
       }

       export function getDevice() {
           // デバイス取得処理
           const ua = navigator.userAgent.toLowerCase()
           if (/android|iphone|ipad|ipod/.test(ua)) {
               return 'mobile'
           } else {
               return 'pc'
           }
       }

       export function getPicture(mode = 1) {
           // 画像取得処理
           const input = document.createElement('input');
           // カメラ撮影かファイル選択かを判定
           input.type = 'file'
           if (mode === 0) input.capture = 'environment';
           input.accept = 'image/*';
           input.onchange = (event: any) => {
               const file = event.target.files[0];
               if (file) {
                   const reader = new FileReader();
                   reader.onload = async () => {
                       const imageData = reader.result;
                       (document.querySelector('.facePicture') as HTMLImageElement).setAttribute("src", imageData as string);
                       // 画面イベントを発火
                       (document.querySelector('#gameContainer') as HTMLElement).dispatchEvent(new PointerEvent('click', { bubbles: true }));

                   };
                   reader.readAsDataURL(file);
               }
           };
           input.click();
       }

       export const shareToX = (result: {[key:string]: string | number}) => {
           const text = `私のシャリ男度は${result.score}%です!\n 「${result.message}」 \n アドバイス: ${result.advice}\n\n #シャリ男度測定 #クソアプリアドベントカレンダー`
           const url = 'https://shari-face-detector.vercel.app/'
           const encodedText = encodeURIComponent(text)
           const encodedUrl = encodeURIComponent(url)
           const shareUrl = `https://twitter.com/intent/tweet?text=${encodedText}&url=${encodedUrl}`
           // ポップアップウィンドウを開く
           window.open(
               shareUrl,
               'share_x',
               'width=550,height=420,menubar=no,toolbar=no,scrollbars=yes'
           )
           return true
       }
  </script>
</scene>

シーンタグ(<scene></scene>)で、囲むとパーサーが、その内部をWebTaleScriptと認識して、JSONに変換します。

シナリオタグ(<scenario></scenario>)の中に、ゲームシナリオを WebTaleScript を記述していきます。

そして、スクリプトタグ(<script></script>)の中に、シナリオタグから呼び出すメソッドを記述します。

各タグの解説

画像表示

<show mode="bg" src="./src/resource/background/title_bg3.png" />
このタグで、背景画像・キャラクター・その他画像を画面に表示します。
ブラウザで読み込める画像なら、何でも行けるので、JPEG/GIF/PNGは、もちろんWebPもいけます。

条件分岐

<if condition="getDevice() == 'mobile'">
    <then>
        <!-- Trueの場合、ここに記述されたWebTaleScriptを実行する -->
    </then>
    <else>             
        <!-- Falseの場合、ここに記述されたWebTaleScriptを実行する -->
    </else>
</if>     

みんな大好き条件分岐、condition属性の中に書いたJSを評価して、trueなら、<then>の中に、falseなら、<else>の中に記述したのWebTaleScriptを実行します。

選択肢表示

<choice prompt="どちらで判定しますか?">
    <item label="判定を開始する(カメラ撮影)">
        <call method="getPicture(0)" />
    </item>
    <item label="判定を開始する(画像から)">
        <call method="getPicture(1)" />
        <text>画像を選んだら、タップしてください</text>
    </item>
</choice>

ノベルゲームに欠かせない選択肢表示タグです。
<choice></choice>で囲み、<item></item>で、選択肢の文言と選択されたアクションを記述します。

文字表示

<text>画像を選んだら、タップしてください</text>

セリフじゃないナレーションや字の文を表示するタグです。
太字や斜線にしたり、文字色を変更、<br>で改行もできます。
文中に<wait/>と記述すると、そこで一時停止も可能です。

JavaScript(TypeScript)呼び出し

<call method="getPicture(1)" />

method属性で<script>タグに記述したメソッドを呼び出します。
type="text/typescript"と、記述することで、TypeScriptでの記述も可能です。

呼び出しメソッドの解説

デバイス判定(getDevice)

ユーザーエージェントを確認して、モバイルデバイスかPCかを判定します。
モバイルデバイスの場合はカメラ撮影オプションを表示するために使用しています。

画像取得(getPicture)

カメラ撮影または画像ファイル選択のための処理を行います。
mode=0の場合はカメラ撮影、mode=1の場合はファイル選択になります。
取得した画像はBase64形式でIMG要素に表示します。

判定処理(judge)

神の御業に解説など、恐れ多い
シャリ度を0-100の範囲でランダムに生成し、
スコアに応じて5段階(perfect, high, medium, low, critical)の
判定結果とアドバイスを返します。

結果表示(showResult)

判定結果をプログレスバーと共に画面に表示します。
シャリ度のスコア、判定コメント、アドバイスを表示します。

X投稿(shareToX)

結果をハッシュタグ付きでX(旧Twitter)に投稿するためのポップアップを表示します。

最後に

今年もまた、役に立たないものを作ってしまった。
夏は暑いように、冬はクソアプリなのである。
あくまでジョークアプリなので、判定結果を真に受けたせず、「草www」で、受け流してもらえると助かります。
万が一、このアプリで被害を受けても、責任を負いかねます。

あと、webTaleKitに興味があったら、使ってみて、感想ください。

Discussion