推しアイデア
あえてUXが悪いサイトを作ってみた。
―
あえてUXが悪いサイトを作ってみた。
みんなと同じことしても楽しくない
・CHAPTHA&ウォーリーを探せ風ゲーム ・Bedrockを使用したパスワード煽り機能 ・黒電話UI
今回は意図的にUXを悪くするために全力を注ぎました。最悪のユーザー体験を身をもって体験したい方のみサイトにアクセスしてみてください。 デモでの事故が怖いので、デモが終わったらURL載せます。
https://github.com/kyiku/hackz-ptera-front
https://github.com/kyiku/hackz-ptera-back
https://github.com/kyiku/hackz-ptera-infra


hackz-ptera/ ├── back/ │ └── .github/workflows/ │ ├── ci.yml ← バックエンドのCI │ └── deploy.yml ← バックエンドのCD(ECSへデプロイ) │ └── front/ └── .github/workflows/ ├── ci.yml ← フロントエンドのCI └── deploy.yml ← フロントエンドのCD(S3へデプロイ)
main (本番環境) ← CD発動、自動デプロイ ↑ マージ develop (開発・統合) ← CIのみ(ビルド確認) ↑ マージ feat/xxx または fix/xxx (作業ブランチ) ← CIのみ
初めてCI/CDを構築したがちゃんと実装できてよかった。

ミニゲーム。ぴょんぴょん跳ねる恐竜が可愛い。
それ以外特になし。

完全ランダム。みんなの名前は3文字です。

これは基礎的な勉強がてらスライダーで73万個くらいの日付が選択できるようにしています。1/1/1-2025/12/21までが選択できます。
これめちゃくちゃフロント凝っています!!!!!!!めっちゃ推しポイント!!フロント触ったことのある人ならこの凄さわかってくれるかなと思います。
数字の配置は本物のロータリーダイヤルと同じく、数字は反時計回りに1〜9、0の順で配置されています
const DIGIT_POSITIONS = [ { digit: '1', angle: 60 }, // 2時の位置 { digit: '2', angle: 33.33 }, // 1時40分くらい { digit: '3', angle: 6.67 }, // 12時20分くらい { digit: '4', angle: -20 }, // 11時20分くらい { digit: '5', angle: -46.67 }, // 10時30分くらい { digit: '6', angle: -73.33 }, // 9時30分くらい { digit: '7', angle: -100 }, // 8時40分くらい { digit: '8', angle: -126.67 }, // 7時50分くらい { digit: '9', angle: -153.33 }, // 7時くらい { digit: '0', angle: 180 }, // 6時(真下) ]
これを三角関数を使って円周上に配置しています。
const radians = (angle - 90) * (Math.PI / 180) const radius = 85 // 中心からの距離 const x = Math.cos(radians) * radius const y = Math.sin(radians) * radius
本物の黒電話には「フィンガーストッパー」という金属の突起があり、ダイヤルを回すとこのストッパーで止まります。これを実現するために
function calculateRequiredRotation(digitAngle: number): number { let rotation = (STOPPER_DISPLAY_ANGLE - digitAngle + 360) % 360 if (rotation === 0) { rotation = 360 } return Math.min(rotation, 340) }
ユーザーが数字の穴をドラッグすると、ダイヤルがカーソルに追従して回転します。 角度の計算:
const calculateAngle = useCallback((clientX: number, clientY: number) => { const rect = dialRef.current.getBoundingClientRect() const centerX = rect.left + rect.width / 2 // ダイヤルの中心X const centerY = rect.top + rect.height / 2 // ダイヤルの中心Y // atan2で中心からカーソルへの角度を計算 return Math.atan2(clientY - centerY, clientX - centerX) * (180 / Math.PI) }, [])
回転量の制限:
onst newRotation = Math.max(0, Math.min(requiredRotation, accumulatedRotationRef.current)) setRotation(newRotation)
本物の黒電話は、指を離すとゼンマイの力でゆっくり戻ります。これをCSSトランジションで再現しています。
style={{ transform: `rotate(${rotation}deg)`, transitionDuration: isReturning ? '1000ms' : '0ms', // 戻るときだけアニメーション transitionTimingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)', // ゆっくり減速 }}
戻りの速度は、回転量に応じて変わります(たくさん回したほど戻りに時間がかかる):
const returnDuration = 800 + (rotation / 100) * 400 // 800ms〜1200ms
ストッパーまで回しきったかどうかを判定します。
// 必要回転量の90%以上回したら入力完了 if (rotation >= requiredRotation * 0.9) { setIsReturning(true) // 戻りアニメーション後に数字を確定 setTimeout(() => { onDigitComplete(selectedDigit) // 親コンポーネントに通知 setIsReturning(false) }, returnDuration) }

Google map apiを使用してストリートビューを表示しています。スタート地点は聖地ハックツオフィス。
MediaPipe Face Landmarkerを使用しています。瞬きを検出するために「EAR(目のアスペクト比)」という指標を使います。これは目の縦幅と横幅の比率で、目が開いているときは大きく、閉じているときは小さくなります。
計算方法 MediaPipeが検出した目のランドマーク(上まぶた、下まぶた、目頭、目尻)を使って計算します。
// 目のランドマークインデックス const LEFT_EYE = { top: [159, 158], // 上まぶた(2点) bottom: [145, 153], // 下まぶた(2点) left: 33, // 目頭 right: 133, // 目尻 } // EAR計算 function calculateEAR(landmarks, eye) { // 垂直距離(上まぶた〜下まぶた)を2箇所で計算し、平均を取る const v1 = Math.abs(landmarks[eye.top[0]].y - landmarks[eye.bottom[0]].y) const v2 = Math.abs(landmarks[eye.top[1]].y - landmarks[eye.bottom[1]].y) const verticalAvg = (v1 + v2) / 2 // 水平距離(目頭〜目尻) const horizontal = Math.abs(landmarks[eye.left].x - landmarks[eye.right].x) // EAR = 垂直 / 水平 return verticalAvg / horizontal }
なぜ2点で測るのか: 最初に実装したときにかなり精度が悪かったので、原因を調べたところ目は完全な楕円ではないため、1点だけだと誤差が大きくなっていました。2点で測って平均を取ることで精度を上げています。
瞬きが検出されたら、その長さでモールス信号を決定します。
// パラメータ dotMaxMs = 350 // 350ms以下 → ドット dashMaxMs = 1500 // 1500ms以下 → ダッシュ(それ以上は無効) minBlinkMs = 80 // 80ms以下 → ノイズとして無視 charGapMs = 1500 // 1.5秒入力なし → 文字確定
判定ロジック:
// EARが閾値以下 → 瞬き開始 if (avgEAR < dynamicThreshold) { if (!blinkStartTimeRef.current) { blinkStartTimeRef.current = Date.now() // 瞬き開始時刻を記録 setIsBlinking(true) } } else { // EARが閾値以上 → 目が開いた(瞬き終了) if (blinkStartTimeRef.current) { const blinkDuration = Date.now() - blinkStartTimeRef.current if (blinkDuration >= minBlinkMs && blinkDuration <= dashMaxMs) { // 有効な瞬き const type = blinkDuration <= dotMaxMs ? 'dot' : 'dash' onBlinkDetected({ type, duration: blinkDuration }) } blinkStartTimeRef.current = null } }
https://docs.google.com/document/d/1XjXTe_NjJ7uNbPhZ18gtklPuduYeYRKgzb2lcDAaJ-4/edit?usp=sharing
Web Speech API による音声認識です。 読み上げ進捗のハイライト表示
読み上げた部分は緑色でハイライトされ、残りの部分はグレーで表示されます。これにより、ユーザーは今どこまで読んだかを視覚的に確認できます。
<p className="whitespace-pre-wrap leading-relaxed"> <span className="text-green-600 font-medium"> {termsText.slice(0, matchedCharCount)} {/* 読み上げ済み */} </span> <span className="text-gray-600"> {termsText.slice(matchedCharCount)} {/* 未読部分 */} </span> </p>
この機能は、ユーザーがパスワードを入力するとAIがリアルタイムで解析して毒舌コメントを返すというものです。さらに嫌がらせなのは、パスワードを一文字ずつ読み上げた後に煽りメッセージをWeb Speech API によって音声で読み上げるという仕様です。
バックエンドではAWS Bedrockを通じてClaude 3 Haikuモデルを呼び出しています。
プロンプト設計 AIには「辛口でちょっと意地悪なパスワード分析AI」というキャラクター設定を与えています:
func (c *BedrockClient) buildPrompt(password string) string { return fmt.Sprintf(`あなたは辛口でちょっと意地悪なパスワード分析AIです。ユーザーを煽りながら、パスワードの危険性を指摘してください。 パスワードに含まれる数字から誕生日を推測してください(例: 0315→3月15日生まれ?、19980101→1998年1月1日?) パスワードに含まれる英字から名前を推測してください(例: yuki→ゆきさん?、taro→たろうくん?) 彼氏・彼女・ペットの名前かもしれないと言及してください。 煽り方の例: - 「それ、SNSを3分見れば分かりますよ」 - 「ハッカーが最初に試すパターンですね」 - 「その程度のパスワード、私なら5秒で突破できます」 - 「恋人の名前入れてません?バレバレですよ」 パスワード: %s 1-2文で、毒舌&煽りを込めて日本語で回答してください。絶対に褒めないでください。`, password) }
フォールバック機能 Bedrock APIが失敗した場合でも、バックエンド側でパターンマッチングによるフォールバックを用意しています
レスポンス早め、低コスト、日本語対応のためClaude 3 Haikuを使用しています。

[S3: 背景画像] + [S3: キャラクター画像4種] ↓ [Go image パッケージで合成] ↓ [S3にアップロード] → [CloudFront経由で配信]
GenerateMultiCharacter関数
CAPTCHA画像の生成は以下の流れで行われます:
func (g *Generator) GenerateMultiCharacter() (*GenerateResult, error) { // 1. ランダムな背景画像を取得 bgImg, err := g.getRandomBackgroundImage() // 2. 4種類のキャラクター画像を取得 characters, err := g.getAllCharacterImages() // 3. ランダムに1種類をターゲット、残り3種類をダミーに targetIdx := rand.Intn(len(characters)) target := characters[targetIdx] dummies := ... // 残り3種類 // 4. ダミーを各30個ずつ配置(合計90個) for _, dummy := range dummies { for i := 0; i < DummiesPerType; i++ { // DummiesPerType = 30 placement, ok := pm.TryPlace() g.drawCharacter(result, dummy.Image, placement) } } // 5. ターゲットを最後に配置(上に描画されるように) targetPlacement, ok := pm.TryPlace() g.drawCharacter(result, target.Image, targetPlacement) }
キャラクターが重なって見えなくなることを防ぐため、衝突検出アルゴリズムを実装しています:
func (pm *PlacementManager) TryPlace() (Placement, bool) { for retry := 0; retry < pm.maxRetries; retry++ { // 最大100回試行 candidate := Placement{ X: rand.Intn(maxX), Y: rand.Intn(maxY), Width: pm.charWidth, Height: pm.charHeight, } // 既存の配置と重ならないかチェック if !pm.hasCollision(candidate) { pm.placements = append(pm.placements, candidate) return candidate, true } } return Placement{}, false // 配置できなかった } func (p Placement) Intersects(other Placement) bool { return p.Bounds().Overlaps(other.Bounds()) }
CatmullRomは高品質なバイキュービック補間アルゴリズムで、リサイズ時のジャギーを抑えます。
クリック判定(バックエンド) ユーザーのクリック座標とターゲットの中心座標のユークリッド距離を計算し、許容範囲内かどうかを判定します:
func (h *CaptchaHandler) Verify(c echo.Context) error { // ユークリッド距離を計算 dx := float64(req.X - user.CaptchaTargetX) dy := float64(req.Y - user.CaptchaTargetY) distance := math.Sqrt(dx*dx + dy*dy) // tolerance = 25px(50x50キャラクターの半分) if distance <= float64(h.tolerance) { // 正解! return c.JSON(http.StatusOK, map[string]interface{}{ "error": false, "next_stage": "registering", "message": "CAPTCHA成功!", }) } // 不正解 → 試行回数をカウント exceeded := user.IncrementCaptchaAttempts() if exceeded { // 3回失敗 → 待機列の最後尾へ return h.handleMaxAttempts(c, user) } }
座標変換(フロントエンド)
画像はブラウザ上でレスポンシブに表示されるため、クリック座標を実画像座標に変換する必要があります:
const handleImageClick = useCallback( async (event: React.MouseEvent<HTMLImageElement>) => { const img = imageRef.current const rect = img.getBoundingClientRect() // 表示サイズと実サイズの比率を計算 const scaleX = img.naturalWidth / rect.width const scaleY = img.naturalHeight / rect.height // クリック座標を実画像座標に変換 const x = Math.round((event.clientX - rect.left) * scaleX) const y = Math.round((event.clientY - rect.top) * scaleY) // サーバーに送信 const response = await verifyCaptcha({ x, y }) } )

通常のOTPはSMSやメールで6桁のコードが送られてきますが、このアプリでは微分の問題を解いて6桁の答えを求めるという仕様です。
問題は答え(OTP)から逆算して生成されます:
func (g *Generator) Generate() (*ProblemResult, error) { // Step 1: まず6桁のOTPをランダム生成(100000〜999999) otp := rand.Intn(900000) + 100000 // Step 2: 代入するxの値を選択(暗算しやすい値) kOptions := []int{10, 20, 50, 100, 200} k := kOptions[rand.Intn(len(kOptions))] // Step 3: 係数aを選択(1〜5の小さい値) aOptions := []int{1, 2, 3, 4, 5} // Step 4: bを逆算 // f'(k) = 2ak + b = OTP より、b = OTP - 2ak for _, candidateA := range aOptions { candidateB := otp - 2*candidateA*k if candidateB > 0 { a = candidateA b = candidateB break } } // Step 5: 定数cはダミー(微分すると消える) c := rand.Intn(100) + 1 return &ProblemResult{ OTP: otp, A: a, B: b, C: c, K: k, ProblemLatex: g.generateLatex(a, b, c, k), } }
セッションでのOTP管理 サーバー側では、ユーザーセッションにOTPコードを保存します:
// 問題生成時 user.OTPCode = problem.OTP user.OTPAttempts = 0 // 検証時 if answer == user.OTPCode { // 正解!登録トークン発行 registerToken := token.GenerateRegisterToken(user) return c.JSON(http.StatusOK, map[string]interface{}{ "register_token": registerToken, }) } // 不正解 → 試行回数インクリメント exceeded := user.IncrementOTPAttempts() if exceeded { // 3回失敗 → 待機列へ user.ResetToWaiting() }


予告:赤間駅ーグローバルアリーナRTA近日公開