登録サイト

https://github.com/kyiku/hackz-ptera-front

Go

GitHub

TypeScript

React

AWS

意図的にUXを悪くしています。

Cookie777

紅茶ふりふり

やぎさんバスピス

推しアイデア

あえてUXが悪いサイトを作ってみた。

作った背景

みんなと同じことしても楽しくない

推し技術

・CHAPTHA&ウォーリーを探せ風ゲーム ・Bedrockを使用したパスワード煽り機能 ・黒電話UI

プロジェクト詳細

はじめに

今回は意図的にUXを悪くするために全力を注ぎました。最悪のユーザー体験を身をもって体験したい方のみサイトにアクセスしてみてください。 デモでの事故が怖いので、デモが終わったらURL載せます。

技術スタック

フロントエンド

https://github.com/kyiku/hackz-ptera-front

  • React
  • TypeScript
  • Vite
  • Tailwind CSS
  • Zustand(状態管理)
  • Vitest(テスト)

バックエンド

https://github.com/kyiku/hackz-ptera-back

  • Go
  • Echo
  • gorilla/websocket
  • AWS SDK for Go v2

インフラ

https://github.com/kyiku/hackz-ptera-infra

  • Terraform
  • ECS Fargate
  • ALB
  • CloudFront
  • S3
  • ECR
  • Bedrock

image

技術チャレンジ

  • 初AWS!! 何がどういうサービスで今回必要なサービスが何か、調べながら構成を考えました。アーキテクチャ図を作成したのも今回が初めてで、うまくできているかわかりませんが、間違えてはいない気がします!なかなかうまく行っていて嬉しく思います。初登録だったので$100分のクレジットももらえたので多少雑に使えたのも心理的によかったです。
  • TDD 何度かハッカソンに出場経験があるのですが、今回は初めてテスト駆動開発で進めていきました。事前開発は環境構築のみ行い、ひたすらテストコードを書きました。要件を細かく定義し、issueを作成後、そのissueに対応するテストコードを作成しています。テストコードだけで多分10000行くらいあります。タスクの達成条件としてテストケースが全て通ることを必須にしていました。 image
  • CI/CD構築 これも人生で初めて構築しました。 バックエンドとフロントエンドで別々のCI/CDパイプラインを組んでいます。どちらもGitHub Actionsを使用しています。
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を構築したがちゃんと実装できてよかった。

  • チーム内で一人だけwindowsを使っている人はバージョンがwindows10だったのでwindows11にアップグレードさせていました。大進歩!(4年間アップグレードの通知無視してた)

アプリ機能紹介

  • WebSocketによる待ち列表示 リアルタイム通信で、自分の順番が常に画面に表示されます。自分の番が来るまで待機しなければならない。初めてWebSocketも実装した。通信がある間は新規のアクセスを許可しないようにしていたら、リロードをしたときにセッションが残ってしまいデッドロックのようになってしまって、WebSocketのセッション管理などの細かい設定などにも気を使うことができた。 image

Dino run

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

名前スロット

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

スライダーでの生年月日選択

これは基礎的な勉強がてらスライダーで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

本物の黒電話には「フィンガーストッパー」という金属の突起があり、ダイヤルを回すとこのストッパーで止まります。これを実現するために

  • ストッパーの位置:5時の方向(150度)
  • 数字によって回転量が違う:
  • 「1」は最短(90度くらい回転)
  • 「0」は最長(330度くらい回転、ほぼ1周)
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) }

image

ストリートビューでの住所入力

Google map apiを使用してストリートビューを表示しています。スタート地点は聖地ハックツオフィス。

瞬きのモールス信号でメールアドレス入力

MediaPipe Face Landmarkerを使用しています。瞬きを検出するために「EAR(目のアスペクト比)」という指標を使います。これは目の縦幅と横幅の比率で、目が開いているときは大きく、閉じているときは小さくなります。

  • 目が開いている時: EAR ≒ 0.3〜0.4
  • 目が閉じている時: EAR ≒ 0.1〜0.2

計算方法 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にリアルタイムで解析されるパスワード入力

この機能は、ユーザーがパスワードを入力すると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を使用しています。 image

「ウォーリーを探せ」風のCAPTCHA

[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 }) } )

image

微分OTP

通常の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() }

image

おまけ

image

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

Cookie777

@Cookie777