From 4246f718ef3e7e1fe695be026414b7a2e567b00b Mon Sep 17 00:00:00 2001 From: syui Date: Sun, 1 Jun 2025 21:39:53 +0900 Subject: [PATCH] add claude --- .claude/settings.local.json | 10 + .gitignore | 57 +++ README.md | 63 ++++ api/.env.example | 32 ++ api/Dockerfile | 24 ++ api/alembic.ini | 47 +++ api/alembic/env.py | 82 +++++ api/alembic/script.py.mako | 24 ++ api/app/__init__.py | 1 + api/app/auth/__init__.py | 1 + api/app/auth/dependencies.py | 154 ++++++++ api/app/core/__init__.py | 1 + api/app/core/config.py | 46 +++ api/app/db/__init__.py | 1 + api/app/db/base.py | 40 ++ api/app/db/models.py | 121 +++++++ api/app/main.py | 54 +++ api/app/models/__init__.py | 1 + api/app/models/card.py | 57 +++ api/app/repositories/__init__.py | 1 + api/app/repositories/base.py | 65 ++++ api/app/repositories/card.py | 136 +++++++ api/app/repositories/user.py | 38 ++ api/app/routes/__init__.py | 1 + api/app/routes/auth.py | 132 +++++++ api/app/routes/cards.py | 118 ++++++ api/app/routes/sync.py | 151 ++++++++ api/app/services/__init__.py | 1 + api/app/services/atproto.py | 288 +++++++++++++++ api/app/services/card_sync.py | 184 ++++++++++ api/app/services/gacha.py | 181 ++++++++++ api/app/tests/__init__.py | 1 + api/app/tests/test_gacha.py | 57 +++ api/init_db.py | 71 ++++ api/requirements.txt | 18 + docker-compose.production.yml | 54 +++ docker-compose.yml | 64 ++++ docs/AI_CONTEXT.md | 285 +++++++++++++++ docs/API.md | 102 ++++++ docs/ATPROTO.md | 146 ++++++++ docs/DATABASE.md | 102 ++++++ docs/DEVELOPMENT.md | 124 +++++++ docs/IMPLEMENTATION_SUMMARY.md | 267 ++++++++++++++ ios/AiCard/AiCard.xcodeproj/project.pbxproj | 330 +++++++++++++++++ ios/AiCard/AiCard/AiCardApp.swift | 16 + ios/AiCard/AiCard/ContentView.swift | 41 +++ ios/AiCard/AiCard/Models/Card.swift | 90 +++++ ios/AiCard/AiCard/Models/User.swift | 25 ++ ios/AiCard/AiCard/Services/APIClient.swift | 125 +++++++ ios/AiCard/AiCard/Services/AuthManager.swift | 87 +++++ ios/AiCard/AiCard/Services/CardManager.swift | 73 ++++ .../AiCard/Utils/Color+Extensions.swift | 28 ++ ios/AiCard/AiCard/Views/CardView.swift | 247 +++++++++++++ ios/AiCard/AiCard/Views/CollectionView.swift | 341 ++++++++++++++++++ .../AiCard/Views/GachaAnimationView.swift | 310 ++++++++++++++++ ios/AiCard/AiCard/Views/GachaView.swift | 190 ++++++++++ ios/AiCard/AiCard/Views/LoginView.swift | 157 ++++++++ ios/AiCard/AiCard/Views/ProfileView.swift | 265 ++++++++++++++ ios/Package.swift | 28 ++ ios/README.md | 121 +++++++ scripts/setup.sh | 26 ++ web/Dockerfile | 26 ++ web/index.html | 20 + web/nginx.conf | 19 + web/package.json | 23 ++ web/src/App.css | 174 +++++++++ web/src/App.tsx | 161 +++++++++ web/src/components/Card.tsx | 83 +++++ web/src/components/GachaAnimation.tsx | 84 +++++ web/src/components/Login.tsx | 115 ++++++ web/src/main.tsx | 9 + web/src/services/api.ts | 31 ++ web/src/services/auth.ts | 107 ++++++ web/src/styles/Card.css | 151 ++++++++ web/src/styles/GachaAnimation.css | 120 ++++++ web/src/styles/Login.css | 152 ++++++++ web/src/types/card.ts | 24 ++ web/tsconfig.json | 21 ++ web/tsconfig.node.json | 11 + web/vite.config.ts | 15 + 80 files changed, 7249 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 README.md create mode 100644 api/.env.example create mode 100644 api/Dockerfile create mode 100644 api/alembic.ini create mode 100644 api/alembic/env.py create mode 100644 api/alembic/script.py.mako create mode 100644 api/app/__init__.py create mode 100644 api/app/auth/__init__.py create mode 100644 api/app/auth/dependencies.py create mode 100644 api/app/core/__init__.py create mode 100644 api/app/core/config.py create mode 100644 api/app/db/__init__.py create mode 100644 api/app/db/base.py create mode 100644 api/app/db/models.py create mode 100644 api/app/main.py create mode 100644 api/app/models/__init__.py create mode 100644 api/app/models/card.py create mode 100644 api/app/repositories/__init__.py create mode 100644 api/app/repositories/base.py create mode 100644 api/app/repositories/card.py create mode 100644 api/app/repositories/user.py create mode 100644 api/app/routes/__init__.py create mode 100644 api/app/routes/auth.py create mode 100644 api/app/routes/cards.py create mode 100644 api/app/routes/sync.py create mode 100644 api/app/services/__init__.py create mode 100644 api/app/services/atproto.py create mode 100644 api/app/services/card_sync.py create mode 100644 api/app/services/gacha.py create mode 100644 api/app/tests/__init__.py create mode 100644 api/app/tests/test_gacha.py create mode 100644 api/init_db.py create mode 100644 api/requirements.txt create mode 100644 docker-compose.production.yml create mode 100644 docker-compose.yml create mode 100644 docs/AI_CONTEXT.md create mode 100644 docs/API.md create mode 100644 docs/ATPROTO.md create mode 100644 docs/DATABASE.md create mode 100644 docs/DEVELOPMENT.md create mode 100644 docs/IMPLEMENTATION_SUMMARY.md create mode 100644 ios/AiCard/AiCard.xcodeproj/project.pbxproj create mode 100644 ios/AiCard/AiCard/AiCardApp.swift create mode 100644 ios/AiCard/AiCard/ContentView.swift create mode 100644 ios/AiCard/AiCard/Models/Card.swift create mode 100644 ios/AiCard/AiCard/Models/User.swift create mode 100644 ios/AiCard/AiCard/Services/APIClient.swift create mode 100644 ios/AiCard/AiCard/Services/AuthManager.swift create mode 100644 ios/AiCard/AiCard/Services/CardManager.swift create mode 100644 ios/AiCard/AiCard/Utils/Color+Extensions.swift create mode 100644 ios/AiCard/AiCard/Views/CardView.swift create mode 100644 ios/AiCard/AiCard/Views/CollectionView.swift create mode 100644 ios/AiCard/AiCard/Views/GachaAnimationView.swift create mode 100644 ios/AiCard/AiCard/Views/GachaView.swift create mode 100644 ios/AiCard/AiCard/Views/LoginView.swift create mode 100644 ios/AiCard/AiCard/Views/ProfileView.swift create mode 100644 ios/Package.swift create mode 100644 ios/README.md create mode 100755 scripts/setup.sh create mode 100644 web/Dockerfile create mode 100644 web/index.html create mode 100644 web/nginx.conf create mode 100644 web/package.json create mode 100644 web/src/App.css create mode 100644 web/src/App.tsx create mode 100644 web/src/components/Card.tsx create mode 100644 web/src/components/GachaAnimation.tsx create mode 100644 web/src/components/Login.tsx create mode 100644 web/src/main.tsx create mode 100644 web/src/services/api.ts create mode 100644 web/src/services/auth.ts create mode 100644 web/src/styles/Card.css create mode 100644 web/src/styles/GachaAnimation.css create mode 100644 web/src/styles/Login.css create mode 100644 web/src/types/card.ts create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..14612a0 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:card.syui.ai)", + "Bash(mkdir:*)", + "Bash(chmod:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/.gitignore b/.gitignore index d6244e1..ecc51cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,60 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +.venv/ +.env + +# FastAPI +.pytest_cache/ +.coverage +htmlcov/ +*.log + +# Database +*.db +*.sqlite3 + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# macOS +.DS_Store + +# Node +node_modules/ +dist/ +build/ +.next/ +.nuxt/ +*.log* + +# iOS +*.xcworkspace +xcuserdata/ +*.xcscmblueprint +*.xccheckout +DerivedData/ +*.ipa +*.dSYM.zip +*.dSYM +Pods/ + +# Secrets +.env.local +.env.production +secrets/ +*.key +*.pem + +# Origin node_modules dist tt diff --git a/README.md b/README.md new file mode 100644 index 0000000..be706f2 --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# ai.card + +atprotoベースのカードゲームシステム + +## 概要 + +ai.cardは、ユーザーがデータを所有する分散型カードゲームです。 +- atprotoアカウントと連携 +- データはユーザーのPDSに保存 +- yui-systemによるuniqueカード実装 +- iOS/Web/APIの統合プロジェクト + +## 技術スタック + +- **API**: Python/FastAPI + fastapi_mcp +- **Web**: モダンJavaScript framework +- **iOS**: Swift/SwiftUI +- **データストア**: atproto collection + ローカルキャッシュ +- **認証**: atproto OAuth + +## プロジェクト構造 + +``` +ai.card/ +├── api/ # FastAPI backend +├── web/ # Web frontend +├── ios/ # iOS app +├── docs/ # Documentation +└── scripts/ # Utility scripts +``` + +## 機能 + +- カードガチャシステム +- キラカード(0.1%) +- uniqueカード(0.0001% - 隠し機能) +- atprotoデータ同期 +- 改ざん防止機構 + +## セットアップ + +### API +```bash +cd api +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +### Web +```bash +cd web +npm install +npm run dev +``` + +## 開発状況 + +- [ ] API基盤 +- [ ] カードデータモデル +- [ ] ガチャシステム +- [ ] atproto連携 +- [ ] Web UI +- [ ] iOS app \ No newline at end of file diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..55de024 --- /dev/null +++ b/api/.env.example @@ -0,0 +1,32 @@ +# Application +APP_NAME=ai.card +APP_VERSION=0.1.0 +DEBUG=false + +# Database (Local PostgreSQL) +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/aicard + +# Database (Supabase - optional) +DATABASE_URL_SUPABASE=postgresql+asyncpg://postgres.xxxxxxxxxxxx:password@aws-0-region.pooler.supabase.com:5432/postgres +USE_SUPABASE=false + +# atproto (optional) +ATPROTO_PDS_URL=https://bsky.social +ATPROTO_HANDLE=your.handle +ATPROTO_PASSWORD=your-app-password + +# Card probabilities (in percentage) +PROB_NORMAL=99.789 +PROB_RARE=0.1 +PROB_SUPER_RARE=0.01 +PROB_KIRA=0.1 +PROB_UNIQUE=0.0001 + +# Unique card settings +MAX_UNIQUE_CARDS=1000 + +# CORS +CORS_ORIGINS=["http://localhost:3000", "https://card.syui.ai"] + +# Security +SECRET_KEY=your-secret-key-change-this-in-production \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..023a58a --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application +COPY . . + +# Create non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/api/alembic.ini b/api/alembic.ini new file mode 100644 index 0000000..fb1bbb0 --- /dev/null +++ b/api/alembic.ini @@ -0,0 +1,47 @@ +# Alembic Configuration + +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/aicard + +[post_write_hooks] +hooks = black +black.type = console_scripts +black.entrypoint = black +black.options = -l 88 + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/api/alembic/env.py b/api/alembic/env.py new file mode 100644 index 0000000..e78a11a --- /dev/null +++ b/api/alembic/env.py @@ -0,0 +1,82 @@ +"""Alembic environment configuration""" +import asyncio +from logging.config import fileConfig +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config +from alembic import context +import os +import sys +from pathlib import Path + +# Add parent directory to path +sys.path.append(str(Path(__file__).parent.parent)) + +from app.db.base import Base +from app.db.models import * # Import all models +from app.core.config import settings + +# Alembic Config object +config = context.config + +# Interpret the config file for Python logging +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Model metadata +target_metadata = Base.metadata + +# Override sqlalchemy.url with environment variable if present +if os.getenv("DATABASE_URL"): + config.set_main_option("sqlalchemy.url", os.getenv("DATABASE_URL")) +else: + config.set_main_option("sqlalchemy.url", settings.database_url) + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode with async engine.""" + configuration = config.get_section(config.config_ini_section) + configuration["sqlalchemy.url"] = config.get_main_option("sqlalchemy.url") + + connectable = async_engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/api/alembic/script.py.mako b/api/alembic/script.py.mako new file mode 100644 index 0000000..37d0cac --- /dev/null +++ b/api/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/api/app/__init__.py b/api/app/__init__.py new file mode 100644 index 0000000..0413598 --- /dev/null +++ b/api/app/__init__.py @@ -0,0 +1 @@ +# ai.card API Package \ No newline at end of file diff --git a/api/app/auth/__init__.py b/api/app/auth/__init__.py new file mode 100644 index 0000000..5f5f9e1 --- /dev/null +++ b/api/app/auth/__init__.py @@ -0,0 +1 @@ +# Auth Package \ No newline at end of file diff --git a/api/app/auth/dependencies.py b/api/app/auth/dependencies.py new file mode 100644 index 0000000..75338e5 --- /dev/null +++ b/api/app/auth/dependencies.py @@ -0,0 +1,154 @@ +"""Authentication dependencies""" +from typing import Optional, Annotated +from fastapi import Depends, HTTPException, Header, Cookie +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from jose import JWTError, jwt +from datetime import datetime, timedelta + +from app.services.atproto import AtprotoService +from app.core.config import settings + + +# Bearer token scheme +bearer_scheme = HTTPBearer(auto_error=False) + +# JWT settings +SECRET_KEY = settings.secret_key if hasattr(settings, 'secret_key') else "your-secret-key" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24 hours + + +class AuthUser: + """Authenticated user data""" + def __init__(self, did: str, handle: Optional[str] = None): + self.did = did + self.handle = handle + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + """Create JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +async def verify_token(token: str) -> Optional[AuthUser]: + """Verify JWT token and return user""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + did: str = payload.get("did") + if did is None: + return None + + handle: Optional[str] = payload.get("handle") + return AuthUser(did=did, handle=handle) + + except JWTError: + return None + + +async def get_current_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(bearer_scheme), + token_cookie: Optional[str] = Cookie(None, alias="ai_card_token") +) -> Optional[AuthUser]: + """ + Get current user from JWT token + Supports both Bearer token and cookie + """ + token = None + + # Try Bearer token first + if credentials and credentials.credentials: + token = credentials.credentials + # Fall back to cookie + elif token_cookie: + token = token_cookie + + if not token: + return None + + user = await verify_token(token) + return user + + +async def require_user( + current_user: Optional[AuthUser] = Depends(get_current_user) +) -> AuthUser: + """Require authenticated user""" + if not current_user: + raise HTTPException( + status_code=401, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + return current_user + + +async def get_optional_user( + current_user: Optional[AuthUser] = Depends(get_current_user) +) -> Optional[AuthUser]: + """Get user if authenticated, None otherwise""" + return current_user + + +class AtprotoAuth: + """atproto authentication handler""" + + def __init__(self): + self.service = AtprotoService() + + async def authenticate(self, identifier: str, password: str) -> Optional[AuthUser]: + """ + Authenticate user with atproto + + Args: + identifier: Handle or DID + password: App password + + Returns: + AuthUser if successful + """ + try: + # Login to atproto + session = await self.service.login(identifier, password) + + # Get user info from session + # The session contains the DID + if self.service.client: + did = self.service.client.did + handle = self.service.client.handle + + return AuthUser(did=did, handle=handle) + + return None + + except Exception: + return None + + async def verify_did_ownership(self, did: str, session_string: str) -> bool: + """ + Verify user owns the DID by checking session + + Args: + did: DID to verify + session_string: Session string from login + + Returns: + True if session is valid for DID + """ + try: + self.service.restore_session(session_string) + + if self.service.client and self.service.client.did == did: + return True + + return False + + except Exception: + return False \ No newline at end of file diff --git a/api/app/core/__init__.py b/api/app/core/__init__.py new file mode 100644 index 0000000..e69905e --- /dev/null +++ b/api/app/core/__init__.py @@ -0,0 +1 @@ +# Core Package \ No newline at end of file diff --git a/api/app/core/config.py b/api/app/core/config.py new file mode 100644 index 0000000..1f4f47c --- /dev/null +++ b/api/app/core/config.py @@ -0,0 +1,46 @@ +"""Application configuration""" +from typing import Optional +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # Application + app_name: str = "ai.card" + app_version: str = "0.1.0" + debug: bool = False + + # API + api_v1_prefix: str = "/api/v1" + + # Database + database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/aicard" + database_url_supabase: Optional[str] = None + use_supabase: bool = False + + # atproto + atproto_pds_url: Optional[str] = None + atproto_handle: Optional[str] = None + atproto_password: Optional[str] = None + + # Card probabilities (in percentage) + prob_normal: float = 99.789 + prob_rare: float = 0.1 + prob_super_rare: float = 0.01 + prob_kira: float = 0.1 + prob_unique: float = 0.0001 + + # Unique card settings + max_unique_cards: int = 1000 # Maximum number of unique cards + + # CORS + cors_origins: list[str] = ["http://localhost:3000", "https://card.syui.ai"] + + # Security + secret_key: str = "your-secret-key-change-this-in-production" + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + +settings = Settings() \ No newline at end of file diff --git a/api/app/db/__init__.py b/api/app/db/__init__.py new file mode 100644 index 0000000..425d847 --- /dev/null +++ b/api/app/db/__init__.py @@ -0,0 +1 @@ +# Database Package \ No newline at end of file diff --git a/api/app/db/base.py b/api/app/db/base.py new file mode 100644 index 0000000..237d478 --- /dev/null +++ b/api/app/db/base.py @@ -0,0 +1,40 @@ +"""Database base configuration""" +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from app.core.config import settings + +# Create base class for models +Base = declarative_base() + +# Select database URL based on configuration +database_url = settings.database_url_supabase if settings.use_supabase else settings.database_url + +# Create async engine +engine = create_async_engine( + database_url, + echo=settings.debug, + future=True, + pool_pre_ping=True, # Enable connection health checks + pool_size=5, + max_overflow=10 +) + +# Create async session factory +async_session = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False +) + + +async def get_db() -> AsyncSession: + """Dependency to get database session""" + async with async_session() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() \ No newline at end of file diff --git a/api/app/db/models.py b/api/app/db/models.py new file mode 100644 index 0000000..eee822f --- /dev/null +++ b/api/app/db/models.py @@ -0,0 +1,121 @@ +"""Database models""" +from datetime import datetime +from sqlalchemy import ( + Column, Integer, String, DateTime, Boolean, + Float, ForeignKey, UniqueConstraint, Index, + Enum as SQLEnum +) +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +import uuid +import enum + +from app.db.base import Base +from app.models.card import CardRarity + + +class User(Base): + """ユーザーモデル""" + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + did = Column(String, unique=True, nullable=False, index=True) + handle = Column(String, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) + + # Relationships + cards = relationship("UserCard", back_populates="owner") + draws = relationship("DrawHistory", back_populates="user") + + +class CardMaster(Base): + """カードマスタデータ""" + __tablename__ = "card_master" + + id = Column(Integer, primary_key=True) # 0-15 + name = Column(String, nullable=False) + base_cp_min = Column(Integer, nullable=False) + base_cp_max = Column(Integer, nullable=False) + color = Column(String, nullable=False) + description = Column(String) + + # Relationships + user_cards = relationship("UserCard", back_populates="card_info") + + +class UserCard(Base): + """ユーザー所有カード""" + __tablename__ = "user_cards" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + card_id = Column(Integer, ForeignKey("card_master.id"), nullable=False) + cp = Column(Integer, nullable=False) + status = Column(SQLEnum(CardRarity), nullable=False) + skill = Column(String, nullable=True) + obtained_at = Column(DateTime, default=datetime.utcnow) + is_unique = Column(Boolean, default=False) + unique_id = Column(UUID(as_uuid=True), nullable=True, unique=True) + + # Relationships + owner = relationship("User", back_populates="cards") + card_info = relationship("CardMaster", back_populates="user_cards") + + # Indexes + __table_args__ = ( + Index('idx_user_cards', 'user_id', 'card_id'), + ) + + +class UniqueCardRegistry(Base): + """uniqueカードのグローバルレジストリ""" + __tablename__ = "unique_card_registry" + + id = Column(Integer, primary_key=True) + unique_id = Column(UUID(as_uuid=True), default=uuid.uuid4, unique=True, nullable=False) + card_id = Column(Integer, ForeignKey("card_master.id"), nullable=False) + owner_did = Column(String, ForeignKey("users.did"), nullable=False) + obtained_at = Column(DateTime, default=datetime.utcnow) + verse_skill_id = Column(String, nullable=True) # ai.verse連携用 + + # Unique constraint: 各card_idは1人のみ所有可能 + __table_args__ = ( + UniqueConstraint('card_id', name='unique_card_per_type'), + ) + + +class DrawHistory(Base): + """ガチャ履歴""" + __tablename__ = "draw_history" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + card_id = Column(Integer, nullable=False) + status = Column(SQLEnum(CardRarity), nullable=False) + cp = Column(Integer, nullable=False) + is_paid = Column(Boolean, default=False) + drawn_at = Column(DateTime, default=datetime.utcnow) + + # Relationships + user = relationship("User", back_populates="draws") + + # Indexes + __table_args__ = ( + Index('idx_draw_history_user', 'user_id', 'drawn_at'), + ) + + +class GachaPool(Base): + """ガチャプール(ピックアップ管理)""" + __tablename__ = "gacha_pools" + + id = Column(Integer, primary_key=True) + name = Column(String, nullable=False) + description = Column(String) + is_active = Column(Boolean, default=True) + start_at = Column(DateTime, nullable=False) + end_at = Column(DateTime, nullable=True) + pickup_card_ids = Column(String) # JSON array of card IDs + rate_up_multiplier = Column(Float, default=1.0) + created_at = Column(DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/api/app/main.py b/api/app/main.py new file mode 100644 index 0000000..9c4d61d --- /dev/null +++ b/api/app/main.py @@ -0,0 +1,54 @@ +"""FastAPI application entry point""" +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.core.config import settings +from app.routes import cards, auth, sync + +# Create FastAPI app +app = FastAPI( + title=settings.app_name, + version=settings.app_version, + docs_url="/docs", + redoc_url="/redoc", +) + +# CORS middleware +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include routers +app.include_router(auth.router, prefix=settings.api_v1_prefix) +app.include_router(cards.router, prefix=settings.api_v1_prefix) +app.include_router(sync.router, prefix=settings.api_v1_prefix) + + +@app.get("/") +async def root(): + """Root endpoint""" + return { + "app": settings.app_name, + "version": settings.app_version, + "status": "running" + } + + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy"} + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=True + ) \ No newline at end of file diff --git a/api/app/models/__init__.py b/api/app/models/__init__.py new file mode 100644 index 0000000..fef1916 --- /dev/null +++ b/api/app/models/__init__.py @@ -0,0 +1 @@ +# Models Package \ No newline at end of file diff --git a/api/app/models/card.py b/api/app/models/card.py new file mode 100644 index 0000000..24a2d0e --- /dev/null +++ b/api/app/models/card.py @@ -0,0 +1,57 @@ +"""Card data models""" +from datetime import datetime +from enum import Enum +from typing import Optional +from pydantic import BaseModel, Field + + +class CardRarity(str, Enum): + """カードのレアリティ""" + NORMAL = "normal" + RARE = "rare" + SUPER_RARE = "super_rare" + KIRA = "kira" # キラカード(0.1%) + UNIQUE = "unique" # uniqueカード(0.0001%) + + +class CardBase(BaseModel): + """カードの基本情報""" + id: int = Field(..., ge=0, le=15, description="カード種類ID (0-15)") + cp: int = Field(..., ge=1, le=999, description="カードパワー") + status: CardRarity = Field(default=CardRarity.NORMAL, description="レアリティ") + skill: Optional[str] = Field(None, description="スキル情報") + + +class Card(CardBase): + """所有カード情報""" + owner_did: str = Field(..., description="所有者のatproto DID") + obtained_at: datetime = Field(default_factory=datetime.utcnow, description="取得日時") + is_unique: bool = Field(default=False, description="uniqueカードフラグ") + unique_id: Optional[str] = Field(None, description="unique時のグローバルID") + + class Config: + json_encoders = { + datetime: lambda v: v.isoformat() + } + + +class CardDraw(BaseModel): + """カード抽選リクエスト""" + user_did: str = Field(..., description="ユーザーのDID") + is_paid: bool = Field(default=False, description="課金ガチャかどうか") + + +class CardDrawResult(BaseModel): + """カード抽選結果""" + card: Card + is_new: bool = Field(..., description="新規取得かどうか") + animation_type: str = Field(..., description="演出タイプ") + + +class UniqueCardRegistry(BaseModel): + """uniqueカードの登録情報""" + card_id: int + unique_id: str + owner_did: str + obtained_at: datetime + verse_skill_id: Optional[str] = None \ No newline at end of file diff --git a/api/app/repositories/__init__.py b/api/app/repositories/__init__.py new file mode 100644 index 0000000..ee98481 --- /dev/null +++ b/api/app/repositories/__init__.py @@ -0,0 +1 @@ +# Repositories Package \ No newline at end of file diff --git a/api/app/repositories/base.py b/api/app/repositories/base.py new file mode 100644 index 0000000..38d0ba3 --- /dev/null +++ b/api/app/repositories/base.py @@ -0,0 +1,65 @@ +"""Base repository class""" +from typing import Generic, Type, TypeVar, Optional, List +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, delete +from sqlalchemy.orm import selectinload + +from app.db.base import Base + +ModelType = TypeVar("ModelType", bound=Base) + + +class BaseRepository(Generic[ModelType]): + """Base repository with common CRUD operations""" + + def __init__(self, model: Type[ModelType], session: AsyncSession): + self.model = model + self.session = session + + async def create(self, **kwargs) -> ModelType: + """Create a new record""" + instance = self.model(**kwargs) + self.session.add(instance) + await self.session.flush() + return instance + + async def get(self, id: int) -> Optional[ModelType]: + """Get a record by ID""" + result = await self.session.execute( + select(self.model).where(self.model.id == id) + ) + return result.scalar_one_or_none() + + async def get_multi( + self, + skip: int = 0, + limit: int = 100, + **filters + ) -> List[ModelType]: + """Get multiple records with pagination""" + query = select(self.model) + + # Apply filters + for key, value in filters.items(): + if hasattr(self.model, key): + query = query.where(getattr(self.model, key) == value) + + query = query.offset(skip).limit(limit) + result = await self.session.execute(query) + return result.scalars().all() + + async def update(self, id: int, **kwargs) -> Optional[ModelType]: + """Update a record""" + await self.session.execute( + update(self.model) + .where(self.model.id == id) + .values(**kwargs) + ) + return await self.get(id) + + async def delete(self, id: int) -> bool: + """Delete a record""" + result = await self.session.execute( + delete(self.model).where(self.model.id == id) + ) + return result.rowcount > 0 \ No newline at end of file diff --git a/api/app/repositories/card.py b/api/app/repositories/card.py new file mode 100644 index 0000000..6e12ca1 --- /dev/null +++ b/api/app/repositories/card.py @@ -0,0 +1,136 @@ +"""Card repository""" +from typing import List, Optional +from datetime import datetime +from sqlalchemy import select, and_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload +import uuid + +from app.repositories.base import BaseRepository +from app.db.models import UserCard, UniqueCardRegistry, CardMaster +from app.models.card import CardRarity + + +class CardRepository(BaseRepository[UserCard]): + """Card repository with custom methods""" + + def __init__(self, session: AsyncSession): + super().__init__(UserCard, session) + + async def get_user_cards( + self, + user_id: int, + skip: int = 0, + limit: int = 100 + ) -> List[UserCard]: + """Get all cards for a user""" + result = await self.session.execute( + select(UserCard) + .options(selectinload(UserCard.card_info)) + .where(UserCard.user_id == user_id) + .order_by(UserCard.obtained_at.desc()) + .offset(skip) + .limit(limit) + ) + return result.scalars().all() + + async def count_user_cards(self, user_id: int, card_id: int) -> int: + """Count how many of a specific card a user has""" + result = await self.session.execute( + select(func.count(UserCard.id)) + .where( + and_( + UserCard.user_id == user_id, + UserCard.card_id == card_id + ) + ) + ) + return result.scalar() or 0 + + async def create_user_card( + self, + user_id: int, + card_id: int, + cp: int, + status: CardRarity, + skill: Optional[str] = None, + is_unique: bool = False + ) -> UserCard: + """Create a new user card""" + unique_id = None + if is_unique: + unique_id = uuid.uuid4() + + card = await self.create( + user_id=user_id, + card_id=card_id, + cp=cp, + status=status, + skill=skill, + is_unique=is_unique, + unique_id=unique_id + ) + + # If unique, register it globally + if is_unique: + await self._register_unique_card(card) + + return card + + async def _register_unique_card(self, card: UserCard): + """Register a unique card in the global registry""" + # Get user DID + user_did = await self.session.execute( + select(User.did).where(User.id == card.user_id) + ) + user_did = user_did.scalar() + + registry = UniqueCardRegistry( + unique_id=card.unique_id, + card_id=card.card_id, + owner_did=user_did, + obtained_at=card.obtained_at + ) + self.session.add(registry) + + +class UniqueCardRepository(BaseRepository[UniqueCardRegistry]): + """Unique card registry repository""" + + def __init__(self, session: AsyncSession): + super().__init__(UniqueCardRegistry, session) + + async def is_card_available(self, card_id: int) -> bool: + """Check if a unique card is still available""" + result = await self.session.execute( + select(func.count(UniqueCardRegistry.id)) + .where(UniqueCardRegistry.card_id == card_id) + ) + count = result.scalar() or 0 + return count == 0 + + async def get_all_unique_cards(self) -> List[UniqueCardRegistry]: + """Get all registered unique cards""" + result = await self.session.execute( + select(UniqueCardRegistry) + .order_by(UniqueCardRegistry.obtained_at.desc()) + ) + return result.scalars().all() + + async def get_available_unique_cards(self) -> List[int]: + """Get list of card IDs that are still available as unique""" + # Get all card IDs + all_card_ids = set(range(16)) + + # Get taken card IDs + result = await self.session.execute( + select(UniqueCardRegistry.card_id).distinct() + ) + taken_ids = set(result.scalars().all()) + + # Return available IDs + return list(all_card_ids - taken_ids) + + +# Import User model here to avoid circular import +from app.db.models import User \ No newline at end of file diff --git a/api/app/repositories/user.py b/api/app/repositories/user.py new file mode 100644 index 0000000..a670ecd --- /dev/null +++ b/api/app/repositories/user.py @@ -0,0 +1,38 @@ +"""User repository""" +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.repositories.base import BaseRepository +from app.db.models import User + + +class UserRepository(BaseRepository[User]): + """User repository with custom methods""" + + def __init__(self, session: AsyncSession): + super().__init__(User, session) + + async def get_by_did(self, did: str) -> Optional[User]: + """Get user by DID""" + result = await self.session.execute( + select(User).where(User.did == did) + ) + return result.scalar_one_or_none() + + async def get_or_create(self, did: str, handle: Optional[str] = None) -> User: + """Get existing user or create new one""" + user = await self.get_by_did(did) + if not user: + user = await self.create(did=did, handle=handle) + return user + + async def get_with_cards(self, user_id: int) -> Optional[User]: + """Get user with all their cards""" + result = await self.session.execute( + select(User) + .options(selectinload(User.cards)) + .where(User.id == user_id) + ) + return result.scalar_one_or_none() \ No newline at end of file diff --git a/api/app/routes/__init__.py b/api/app/routes/__init__.py new file mode 100644 index 0000000..0321c28 --- /dev/null +++ b/api/app/routes/__init__.py @@ -0,0 +1 @@ +# Routes Package \ No newline at end of file diff --git a/api/app/routes/auth.py b/api/app/routes/auth.py new file mode 100644 index 0000000..4942262 --- /dev/null +++ b/api/app/routes/auth.py @@ -0,0 +1,132 @@ +"""Authentication routes""" +from datetime import timedelta +from fastapi import APIRouter, HTTPException, Depends, Response +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from sqlalchemy.ext.asyncio import AsyncSession + +from app.auth.dependencies import ( + AtprotoAuth, + create_access_token, + require_user, + AuthUser, + ACCESS_TOKEN_EXPIRE_MINUTES +) +from app.db.base import get_db +from app.repositories.user import UserRepository +from app.services.atproto import AtprotoService + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +class LoginRequest(BaseModel): + """Login request model""" + identifier: str # Handle or DID + password: str # App password + + +class LoginResponse(BaseModel): + """Login response model""" + access_token: str + token_type: str = "bearer" + did: str + handle: str + + +class VerifyResponse(BaseModel): + """Verify response model""" + did: str + handle: str + valid: bool = True + + +@router.post("/login", response_model=LoginResponse) +async def login( + request: LoginRequest, + response: Response, + db: AsyncSession = Depends(get_db) +): + """ + Login with atproto credentials + + - **identifier**: atproto handle or DID + - **password**: App password (not main password) + """ + auth = AtprotoAuth() + + # Authenticate with atproto + user = await auth.authenticate(request.identifier, request.password) + if not user: + raise HTTPException( + status_code=401, + detail="Invalid credentials" + ) + + # Create or update user in database + user_repo = UserRepository(db) + await user_repo.get_or_create(did=user.did, handle=user.handle) + await db.commit() + + # Create access token + access_token = create_access_token( + data={"did": user.did, "handle": user.handle}, + expires_delta=timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + ) + + # Set cookie for web clients + response.set_cookie( + key="ai_card_token", + value=access_token, + httponly=True, + secure=True, + samesite="lax", + max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60 + ) + + return LoginResponse( + access_token=access_token, + did=user.did, + handle=user.handle or "" + ) + + +@router.post("/logout") +async def logout(response: Response): + """Logout and clear session""" + response.delete_cookie("ai_card_token") + return {"message": "Logged out successfully"} + + +@router.get("/verify", response_model=VerifyResponse) +async def verify_session( + current_user: AuthUser = Depends(require_user) +): + """Verify current session is valid""" + return VerifyResponse( + did=current_user.did, + handle=current_user.handle or "", + valid=True + ) + + +@router.post("/verify-did") +async def verify_did(did: str, handle: Optional[str] = None): + """ + Verify DID is valid (public endpoint) + + - **did**: DID to verify + - **handle**: Optional handle to cross-check + """ + service = AtprotoService() + is_valid = await service.verify_did(did, handle) + + return { + "did": did, + "handle": handle, + "valid": is_valid + } + + +# Import Optional here +from typing import Optional \ No newline at end of file diff --git a/api/app/routes/cards.py b/api/app/routes/cards.py new file mode 100644 index 0000000..2d9feb3 --- /dev/null +++ b/api/app/routes/cards.py @@ -0,0 +1,118 @@ +"""Card-related API routes""" +from typing import List +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.card import Card, CardDraw, CardDrawResult +from app.services.gacha import GachaService +from app.repositories.user import UserRepository +from app.repositories.card import CardRepository, UniqueCardRepository +from app.db.base import get_db + +router = APIRouter(prefix="/cards", tags=["cards"]) + + +@router.post("/draw", response_model=CardDrawResult) +async def draw_card( + draw_request: CardDraw, + db: AsyncSession = Depends(get_db) +): + """ + カードを抽選する + + - **user_did**: ユーザーのatproto DID + - **is_paid**: 課金ガチャかどうか + """ + try: + gacha_service = GachaService(db) + card, is_unique = await gacha_service.draw_card( + user_did=draw_request.user_did, + is_paid=draw_request.is_paid + ) + + # 演出タイプを決定 + animation_type = "normal" + if is_unique: + animation_type = "unique" + elif card.status.value == "kira": + animation_type = "kira" + elif card.status.value in ["super_rare", "rare"]: + animation_type = "rare" + + # 新規取得かチェック + user_repo = UserRepository(db) + card_repo = CardRepository(db) + user = await user_repo.get_by_did(draw_request.user_did) + count = await card_repo.count_user_cards(user.id, card.id) + is_new = count == 1 # 今引いたカードが初めてなら1枚 + + result = CardDrawResult( + card=card, + is_new=is_new, + animation_type=animation_type + ) + + await db.commit() + return result + + except Exception as e: + await db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/user/{user_did}", response_model=List[Card]) +async def get_user_cards( + user_did: str, + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db) +): + """ + ユーザーの所有カード一覧を取得 + + - **user_did**: ユーザーのatproto DID + """ + user_repo = UserRepository(db) + card_repo = CardRepository(db) + + user = await user_repo.get_by_did(user_did) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + user_cards = await card_repo.get_user_cards(user.id, skip=skip, limit=limit) + + # Convert to API model + cards = [] + for uc in user_cards: + card = Card( + id=uc.card_id, + cp=uc.cp, + status=uc.status, + skill=uc.skill, + owner_did=user_did, + obtained_at=uc.obtained_at, + is_unique=uc.is_unique, + unique_id=str(uc.unique_id) if uc.unique_id else None + ) + cards.append(card) + + return cards + + +@router.get("/unique") +async def get_unique_cards(db: AsyncSession = Depends(get_db)): + """ + 全てのuniqueカード一覧を取得(所有者情報付き) + """ + unique_repo = UniqueCardRepository(db) + unique_cards = await unique_repo.get_all_unique_cards() + + return [ + { + "card_id": uc.card_id, + "owner_did": uc.owner_did, + "obtained_at": uc.obtained_at, + "unique_id": str(uc.unique_id) + } + for uc in unique_cards + ] \ No newline at end of file diff --git a/api/app/routes/sync.py b/api/app/routes/sync.py new file mode 100644 index 0000000..4322b45 --- /dev/null +++ b/api/app/routes/sync.py @@ -0,0 +1,151 @@ +"""Synchronization routes for atproto""" +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel + +from app.auth.dependencies import require_user, AuthUser +from app.db.base import get_db +from app.services.card_sync import CardSyncService +from app.repositories.user import UserRepository + + +router = APIRouter(prefix="/sync", tags=["sync"]) + + +class SyncRequest(BaseModel): + """Sync request model""" + atproto_session: str # Session string from atproto login + + +class SyncResponse(BaseModel): + """Sync response model""" + synced_to_pds: int = 0 + imported_from_pds: int = 0 + message: str + + +@router.post("/cards", response_model=SyncResponse) +async def sync_cards( + request: SyncRequest, + current_user: AuthUser = Depends(require_user), + db: AsyncSession = Depends(get_db) +): + """ + Sync cards between database and atproto PDS + + - **atproto_session**: Session string from atproto login + """ + try: + # Get user from database + user_repo = UserRepository(db) + user = await user_repo.get_by_did(current_user.did) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Create sync service + sync_service = CardSyncService(db, request.atproto_session) + + # Import from PDS first + imported = await sync_service.import_cards_from_pds(current_user.did) + + # Then sync all cards to PDS + synced = await sync_service.sync_all_user_cards(user.id, current_user.did) + + await db.commit() + + return SyncResponse( + synced_to_pds=synced, + imported_from_pds=imported, + message=f"Successfully synced {synced} cards to PDS and imported {imported} cards from PDS" + ) + + except Exception as e: + await db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/export") +async def export_to_pds( + request: SyncRequest, + current_user: AuthUser = Depends(require_user), + db: AsyncSession = Depends(get_db) +): + """ + Export all cards to atproto PDS + + - **atproto_session**: Session string from atproto login + """ + try: + user_repo = UserRepository(db) + user = await user_repo.get_by_did(current_user.did) + if not user: + raise HTTPException(status_code=404, detail="User not found") + + sync_service = CardSyncService(db, request.atproto_session) + synced = await sync_service.sync_all_user_cards(user.id, current_user.did) + + return { + "exported": synced, + "message": f"Successfully exported {synced} cards to PDS" + } + + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/import") +async def import_from_pds( + request: SyncRequest, + current_user: AuthUser = Depends(require_user), + db: AsyncSession = Depends(get_db) +): + """ + Import cards from atproto PDS + + - **atproto_session**: Session string from atproto login + """ + try: + sync_service = CardSyncService(db, request.atproto_session) + imported = await sync_service.import_cards_from_pds(current_user.did) + + await db.commit() + + return { + "imported": imported, + "message": f"Successfully imported {imported} cards from PDS" + } + + except Exception as e: + await db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/verify/{card_id}") +async def verify_card_ownership( + card_id: int, + unique_id: Optional[str] = None, + current_user: AuthUser = Depends(require_user), + db: AsyncSession = Depends(get_db) +): + """ + Verify user owns a specific card + + - **card_id**: Card type ID (0-15) + - **unique_id**: Unique ID for unique cards + """ + sync_service = CardSyncService(db) + owns_card = await sync_service.verify_card_ownership( + current_user.did, + card_id, + unique_id + ) + + return { + "card_id": card_id, + "unique_id": unique_id, + "owned": owns_card + } + + +# Import Optional +from typing import Optional \ No newline at end of file diff --git a/api/app/services/__init__.py b/api/app/services/__init__.py new file mode 100644 index 0000000..dd3b0fe --- /dev/null +++ b/api/app/services/__init__.py @@ -0,0 +1 @@ +# Services Package \ No newline at end of file diff --git a/api/app/services/atproto.py b/api/app/services/atproto.py new file mode 100644 index 0000000..70c536a --- /dev/null +++ b/api/app/services/atproto.py @@ -0,0 +1,288 @@ +"""atproto integration service""" +import json +from typing import Optional, Dict, Any, List +from datetime import datetime +import httpx +from atproto import Client, SessionString +from atproto.exceptions import AtProtocolError + +from app.core.config import settings +from app.models.card import Card, CardRarity + + +class AtprotoService: + """atproto integration service""" + + def __init__(self): + self.client = None + self.session_string = None + + async def login(self, identifier: str, password: str) -> SessionString: + """ + Login to atproto PDS + + Args: + identifier: Handle or DID + password: App password + + Returns: + Session string for future requests + """ + self.client = Client() + try: + self.client.login(identifier, password) + self.session_string = self.client.export_session_string() + return self.session_string + except AtProtocolError as e: + raise Exception(f"Failed to login to atproto: {str(e)}") + + def restore_session(self, session_string: str): + """Restore session from string""" + self.client = Client() + self.client.login_with_session_string(session_string) + self.session_string = session_string + + async def verify_did(self, did: str, handle: Optional[str] = None) -> bool: + """ + Verify DID is valid + + Args: + did: DID to verify + handle: Optional handle to cross-check + + Returns: + True if valid + """ + try: + # Use public API to resolve DID + async with httpx.AsyncClient() as client: + response = await client.get( + f"https://plc.directory/{did}", + follow_redirects=True + ) + + if response.status_code != 200: + return False + + data = response.json() + + # Verify handle if provided + if handle and data.get("alsoKnownAs"): + expected_handle = f"at://{handle}" + return expected_handle in data["alsoKnownAs"] + + return True + + except Exception: + return False + + async def get_profile(self, did: str) -> Optional[Dict[str, Any]]: + """Get user profile from atproto""" + if not self.client: + raise Exception("Not logged in") + + try: + profile = self.client.get_profile(did) + return { + "did": profile.did, + "handle": profile.handle, + "display_name": profile.display_name, + "avatar": profile.avatar, + "description": profile.description + } + except Exception: + return None + + async def create_card_record( + self, + did: str, + card: Card, + collection: str = "ai.card.collection" + ) -> str: + """ + Create card record in user's PDS + + Args: + did: User's DID + card: Card data + collection: Collection name (lexicon) + + Returns: + Record URI + """ + if not self.client: + raise Exception("Not logged in") + + # Prepare card data for atproto + record_data = { + "$type": collection, + "cardId": card.id, + "cp": card.cp, + "status": card.status.value, + "skill": card.skill, + "obtainedAt": card.obtained_at.isoformat(), + "isUnique": card.is_unique, + "uniqueId": card.unique_id, + "createdAt": datetime.utcnow().isoformat() + } + + try: + # Create record + response = self.client.com.atproto.repo.create_record( + repo=did, + collection=collection, + record=record_data + ) + + return response.uri + + except AtProtocolError as e: + raise Exception(f"Failed to create card record: {str(e)}") + + async def get_user_cards( + self, + did: str, + collection: str = "ai.card.collection", + limit: int = 100 + ) -> List[Dict[str, Any]]: + """ + Get user's cards from PDS + + Args: + did: User's DID + collection: Collection name + limit: Maximum records to fetch + + Returns: + List of card records + """ + if not self.client: + raise Exception("Not logged in") + + try: + # List records + response = self.client.com.atproto.repo.list_records( + repo=did, + collection=collection, + limit=limit + ) + + cards = [] + for record in response.records: + card_data = record.value + card_data["uri"] = record.uri + card_data["cid"] = record.cid + cards.append(card_data) + + return cards + + except AtProtocolError: + # Collection might not exist yet + return [] + + async def delete_card_record(self, did: str, record_uri: str): + """Delete a card record from PDS""" + if not self.client: + raise Exception("Not logged in") + + try: + # Parse collection and rkey from URI + # Format: at://did/collection/rkey + parts = record_uri.split("/") + if len(parts) < 5: + raise ValueError("Invalid record URI") + + collection = parts[3] + rkey = parts[4] + + self.client.com.atproto.repo.delete_record( + repo=did, + collection=collection, + rkey=rkey + ) + + except AtProtocolError as e: + raise Exception(f"Failed to delete record: {str(e)}") + + async def create_oauth_session(self, code: str, redirect_uri: str) -> Dict[str, Any]: + """ + Handle OAuth callback and create session + + Args: + code: Authorization code + redirect_uri: Redirect URI used in authorization + + Returns: + Session data including DID and access token + """ + # TODO: Implement when atproto OAuth is available + raise NotImplementedError("OAuth support is not yet available in atproto") + + +class CardLexicon: + """Card collection lexicon definition""" + + LEXICON_ID = "ai.card.collection" + + @staticmethod + def get_lexicon() -> Dict[str, Any]: + """Get lexicon definition for card collection""" + return { + "lexicon": 1, + "id": CardLexicon.LEXICON_ID, + "defs": { + "main": { + "type": "record", + "description": "A collectible card", + "key": "tid", + "record": { + "type": "object", + "required": ["cardId", "cp", "status", "obtainedAt", "createdAt"], + "properties": { + "cardId": { + "type": "integer", + "description": "Card type ID (0-15)", + "minimum": 0, + "maximum": 15 + }, + "cp": { + "type": "integer", + "description": "Card power", + "minimum": 1, + "maximum": 999 + }, + "status": { + "type": "string", + "description": "Card rarity", + "enum": ["normal", "rare", "super_rare", "kira", "unique"] + }, + "skill": { + "type": "string", + "description": "Card skill", + "maxLength": 1000 + }, + "obtainedAt": { + "type": "string", + "format": "datetime", + "description": "When the card was obtained" + }, + "isUnique": { + "type": "boolean", + "description": "Whether this is a unique card", + "default": False + }, + "uniqueId": { + "type": "string", + "description": "Global unique identifier", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Record creation time" + } + } + } + } + } + } \ No newline at end of file diff --git a/api/app/services/card_sync.py b/api/app/services/card_sync.py new file mode 100644 index 0000000..7aa9477 --- /dev/null +++ b/api/app/services/card_sync.py @@ -0,0 +1,184 @@ +"""Card synchronization service for atproto""" +from typing import List, Dict, Any, Optional +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.atproto import AtprotoService, CardLexicon +from app.repositories.card import CardRepository +from app.repositories.user import UserRepository +from app.models.card import Card as CardModel +from app.db.models import UserCard +from app.core.config import settings + + +class CardSyncService: + """Service for syncing cards between database and atproto PDS""" + + def __init__(self, session: AsyncSession, atproto_session: Optional[str] = None): + self.db_session = session + self.card_repo = CardRepository(session) + self.user_repo = UserRepository(session) + self.atproto_service = AtprotoService() + + # Restore atproto session if provided + if atproto_session: + self.atproto_service.restore_session(atproto_session) + + async def sync_card_to_pds(self, user_card: UserCard, user_did: str) -> Optional[str]: + """ + Sync a single card to user's PDS + + Args: + user_card: Card from database + user_did: User's DID + + Returns: + Record URI if successful + """ + if not settings.atproto_handle or not settings.atproto_password: + # Skip if atproto credentials not configured + return None + + try: + # Login if not already + if not self.atproto_service.client: + await self.atproto_service.login( + settings.atproto_handle, + settings.atproto_password + ) + + # Convert to API model + card_model = CardModel( + id=user_card.card_id, + cp=user_card.cp, + status=user_card.status, + skill=user_card.skill, + owner_did=user_did, + obtained_at=user_card.obtained_at, + is_unique=user_card.is_unique, + unique_id=str(user_card.unique_id) if user_card.unique_id else None + ) + + # Create record in PDS + uri = await self.atproto_service.create_card_record( + did=user_did, + card=card_model, + collection=CardLexicon.LEXICON_ID + ) + + # Store URI in database for future reference + # (You might want to add a field to UserCard model for this) + + return uri + + except Exception as e: + print(f"Failed to sync card to PDS: {e}") + return None + + async def sync_all_user_cards(self, user_id: int, user_did: str) -> int: + """ + Sync all user's cards to PDS + + Args: + user_id: Database user ID + user_did: User's DID + + Returns: + Number of cards synced + """ + # Get all user cards from database + user_cards = await self.card_repo.get_user_cards(user_id) + + synced_count = 0 + for card in user_cards: + uri = await self.sync_card_to_pds(card, user_did) + if uri: + synced_count += 1 + + return synced_count + + async def import_cards_from_pds(self, user_did: str) -> int: + """ + Import cards from user's PDS to database + + Args: + user_did: User's DID + + Returns: + Number of cards imported + """ + if not self.atproto_service.client: + return 0 + + # Get user from database + user = await self.user_repo.get_by_did(user_did) + if not user: + return 0 + + # Get cards from PDS + pds_cards = await self.atproto_service.get_user_cards( + did=user_did, + collection=CardLexicon.LEXICON_ID + ) + + imported_count = 0 + for pds_card in pds_cards: + # Check if card already exists + existing_count = await self.card_repo.count_user_cards( + user.id, + pds_card.get("cardId") + ) + + if existing_count == 0: + # Import card + await self.card_repo.create_user_card( + user_id=user.id, + card_id=pds_card.get("cardId"), + cp=pds_card.get("cp"), + status=pds_card.get("status"), + skill=pds_card.get("skill"), + is_unique=pds_card.get("isUnique", False) + ) + imported_count += 1 + + await self.db_session.commit() + return imported_count + + async def verify_card_ownership( + self, + user_did: str, + card_id: int, + unique_id: Optional[str] = None + ) -> bool: + """ + Verify user owns a card by checking both database and PDS + + Args: + user_did: User's DID + card_id: Card type ID + unique_id: Unique ID for unique cards + + Returns: + True if user owns the card + """ + # Check database first + user = await self.user_repo.get_by_did(user_did) + if user: + user_cards = await self.card_repo.get_user_cards(user.id) + for card in user_cards: + if card.card_id == card_id: + if not unique_id or str(card.unique_id) == unique_id: + return True + + # Check PDS if configured + if self.atproto_service.client: + try: + pds_cards = await self.atproto_service.get_user_cards(user_did) + for card in pds_cards: + if card.get("cardId") == card_id: + if not unique_id or card.get("uniqueId") == unique_id: + return True + except Exception: + pass + + return False \ No newline at end of file diff --git a/api/app/services/gacha.py b/api/app/services/gacha.py new file mode 100644 index 0000000..1697b30 --- /dev/null +++ b/api/app/services/gacha.py @@ -0,0 +1,181 @@ +"""ガチャシステムのロジック""" +import random +import uuid +from datetime import datetime +from typing import Optional, Tuple +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.models.card import Card, CardRarity +from app.repositories.user import UserRepository +from app.repositories.card import CardRepository, UniqueCardRepository +from app.db.models import DrawHistory + + +class GachaService: + """ガチャシステムのサービスクラス""" + + # カード基本情報(ai.jsonから) + CARD_INFO = { + 0: {"name": "ai", "base_cp_range": (10, 100)}, + 1: {"name": "dream", "base_cp_range": (20, 120)}, + 2: {"name": "radiance", "base_cp_range": (30, 130)}, + 3: {"name": "neutron", "base_cp_range": (40, 140)}, + 4: {"name": "sun", "base_cp_range": (50, 150)}, + 5: {"name": "night", "base_cp_range": (25, 125)}, + 6: {"name": "snow", "base_cp_range": (15, 115)}, + 7: {"name": "thunder", "base_cp_range": (60, 160)}, + 8: {"name": "ultimate", "base_cp_range": (80, 180)}, + 9: {"name": "sword", "base_cp_range": (70, 170)}, + 10: {"name": "destruction", "base_cp_range": (90, 190)}, + 11: {"name": "earth", "base_cp_range": (35, 135)}, + 12: {"name": "galaxy", "base_cp_range": (65, 165)}, + 13: {"name": "create", "base_cp_range": (75, 175)}, + 14: {"name": "supernova", "base_cp_range": (100, 200)}, + 15: {"name": "world", "base_cp_range": (85, 185)}, + } + + def __init__(self, session: AsyncSession): + self.session = session + self.user_repo = UserRepository(session) + self.card_repo = CardRepository(session) + self.unique_repo = UniqueCardRepository(session) + + async def draw_card(self, user_did: str, is_paid: bool = False) -> Tuple[Card, bool]: + """ + カードを抽選する + + Args: + user_did: ユーザーのDID + is_paid: 課金ガチャかどうか + + Returns: + (Card, is_unique): 抽選されたカードとuniqueかどうか + """ + # Get or create user + user = await self.user_repo.get_or_create(user_did) + # レアリティ抽選 + rarity = self._determine_rarity(is_paid) + + # カード種類を選択 + card_id = self._select_card_id(rarity) + + # CPを決定 + cp = self._calculate_cp(card_id, rarity) + + # uniqueカードチェック + is_unique = False + + if rarity == CardRarity.UNIQUE: + # uniqueカードの場合、利用可能かチェック + is_available = await self.unique_repo.is_card_available(card_id) + if not is_available: + # 利用不可の場合はキラカードに変更 + rarity = CardRarity.KIRA + else: + is_unique = True + + # データベースにカードを保存 + user_card = await self.card_repo.create_user_card( + user_id=user.id, + card_id=card_id, + cp=cp, + status=rarity, + skill=self._get_skill_for_card(card_id, rarity), + is_unique=is_unique + ) + + # 抽選履歴を保存 + draw_history = DrawHistory( + user_id=user.id, + card_id=card_id, + status=rarity, + cp=cp, + is_paid=is_paid + ) + self.session.add(draw_history) + + # API用のCardモデルに変換 + card = Card( + id=card_id, + cp=cp, + status=rarity, + skill=user_card.skill, + owner_did=user_did, + obtained_at=user_card.obtained_at, + is_unique=is_unique, + unique_id=str(user_card.unique_id) if user_card.unique_id else None + ) + + # atproto PDSに同期(非同期で実行) + try: + from app.services.card_sync import CardSyncService + sync_service = CardSyncService(self.session) + await sync_service.sync_card_to_pds(user_card, user_did) + except Exception: + # 同期失敗してもガチャは成功とする + pass + + return card, is_unique + + def _determine_rarity(self, is_paid: bool) -> CardRarity: + """レアリティを抽選する""" + rand = random.random() * 100 + + if is_paid: + # 課金ガチャは確率アップ + if rand < settings.prob_unique * 2: # 0.0002% + return CardRarity.UNIQUE + elif rand < settings.prob_kira * 2: # 0.2% + return CardRarity.KIRA + elif rand < 0.5: # 0.5% + return CardRarity.SUPER_RARE + elif rand < 5: # 5% + return CardRarity.RARE + else: + # 通常ガチャ + if rand < settings.prob_unique: + return CardRarity.UNIQUE + elif rand < settings.prob_kira: + return CardRarity.KIRA + elif rand < settings.prob_super_rare: + return CardRarity.SUPER_RARE + elif rand < settings.prob_rare: + return CardRarity.RARE + + return CardRarity.NORMAL + + def _select_card_id(self, rarity: CardRarity) -> int: + """レアリティに応じてカードIDを選択""" + if rarity in [CardRarity.UNIQUE, CardRarity.KIRA]: + # レアカードは特定のIDに偏らせる + weights = [1, 1, 2, 2, 3, 1, 1, 3, 5, 4, 5, 2, 3, 4, 6, 5] + else: + # 通常は均等 + weights = [1] * 16 + + return random.choices(range(16), weights=weights)[0] + + def _calculate_cp(self, card_id: int, rarity: CardRarity) -> int: + """カードのCPを計算""" + base_range = self.CARD_INFO[card_id]["base_cp_range"] + base_cp = random.randint(*base_range) + + # レアリティボーナス + multiplier = { + CardRarity.NORMAL: 1.0, + CardRarity.RARE: 1.5, + CardRarity.SUPER_RARE: 2.0, + CardRarity.KIRA: 3.0, + CardRarity.UNIQUE: 5.0, + }[rarity] + + return int(base_cp * multiplier) + + def _get_skill_for_card(self, card_id: int, rarity: CardRarity) -> Optional[str]: + """カードのスキルを取得""" + if rarity in [CardRarity.KIRA, CardRarity.UNIQUE]: + # TODO: スキル情報を返す + return f"skill_{card_id}_{rarity.value}" + return None + diff --git a/api/app/tests/__init__.py b/api/app/tests/__init__.py new file mode 100644 index 0000000..7945106 --- /dev/null +++ b/api/app/tests/__init__.py @@ -0,0 +1 @@ +# Test Package \ No newline at end of file diff --git a/api/app/tests/test_gacha.py b/api/app/tests/test_gacha.py new file mode 100644 index 0000000..081b151 --- /dev/null +++ b/api/app/tests/test_gacha.py @@ -0,0 +1,57 @@ +"""ガチャシステムのテスト""" +import pytest +from app.services.gacha import GachaService +from app.models.card import CardRarity + + +class TestGachaService: + """GachaServiceのテストクラス""" + + def test_determine_rarity_normal(self): + """通常ガチャのレアリティ判定テスト""" + rarities = [] + for _ in range(1000): + rarity = GachaService._determine_rarity(is_paid=False) + rarities.append(rarity) + + # 大部分がNORMALであることを確認 + normal_count = rarities.count(CardRarity.NORMAL) + assert normal_count > 900 + + def test_determine_rarity_paid(self): + """課金ガチャのレアリティ判定テスト""" + rarities = [] + for _ in range(1000): + rarity = GachaService._determine_rarity(is_paid=True) + rarities.append(rarity) + + # 課金ガチャの方がレアが出やすいことを確認 + rare_count = sum(1 for r in rarities if r != CardRarity.NORMAL) + assert rare_count > 50 # 5%以上 + + def test_card_id_selection(self): + """カードID選択のテスト""" + for rarity in CardRarity: + card_id = GachaService._select_card_id(rarity) + assert 0 <= card_id <= 15 + + def test_cp_calculation(self): + """CP計算のテスト""" + # 通常カード + cp_normal = GachaService._calculate_cp(0, CardRarity.NORMAL) + assert 10 <= cp_normal <= 100 + + # uniqueカード(5倍) + cp_unique = GachaService._calculate_cp(0, CardRarity.UNIQUE) + assert 50 <= cp_unique <= 500 + + @pytest.mark.asyncio + async def test_draw_card(self): + """カード抽選の統合テスト""" + user_did = "did:plc:test123" + card, is_unique = await GachaService.draw_card(user_did, is_paid=False) + + assert card.owner_did == user_did + assert 0 <= card.id <= 15 + assert card.cp > 0 + assert card.status in CardRarity \ No newline at end of file diff --git a/api/init_db.py b/api/init_db.py new file mode 100644 index 0000000..87717d9 --- /dev/null +++ b/api/init_db.py @@ -0,0 +1,71 @@ +"""Initialize database with master data""" +import asyncio +from sqlalchemy.ext.asyncio import AsyncSession +from app.db.base import engine, Base, async_session +from app.db.models import CardMaster +from app.core.config import settings + +# Card master data from ai.json +CARD_MASTER_DATA = [ + {"id": 0, "name": "ai", "base_cp_min": 10, "base_cp_max": 100, "color": "#fff700", "description": "世界の最小単位"}, + {"id": 1, "name": "dream", "base_cp_min": 20, "base_cp_max": 120, "color": "#b19cd9", "description": "意識が物質を作る"}, + {"id": 2, "name": "radiance", "base_cp_min": 30, "base_cp_max": 130, "color": "#ffd700", "description": "存在は光に向かう"}, + {"id": 3, "name": "neutron", "base_cp_min": 40, "base_cp_max": 140, "color": "#cacfd2", "description": "中性子"}, + {"id": 4, "name": "sun", "base_cp_min": 50, "base_cp_max": 150, "color": "#ff6b35", "description": "太陽"}, + {"id": 5, "name": "night", "base_cp_min": 25, "base_cp_max": 125, "color": "#1a1a2e", "description": "夜空"}, + {"id": 6, "name": "snow", "base_cp_min": 15, "base_cp_max": 115, "color": "#e3f2fd", "description": "雪"}, + {"id": 7, "name": "thunder", "base_cp_min": 60, "base_cp_max": 160, "color": "#ffd93d", "description": "雷"}, + {"id": 8, "name": "ultimate", "base_cp_min": 80, "base_cp_max": 180, "color": "#6c5ce7", "description": "超究"}, + {"id": 9, "name": "sword", "base_cp_min": 70, "base_cp_max": 170, "color": "#a8e6cf", "description": "剣"}, + {"id": 10, "name": "destruction", "base_cp_min": 90, "base_cp_max": 190, "color": "#ff4757", "description": "破壊"}, + {"id": 11, "name": "earth", "base_cp_min": 35, "base_cp_max": 135, "color": "#4834d4", "description": "地球"}, + {"id": 12, "name": "galaxy", "base_cp_min": 65, "base_cp_max": 165, "color": "#9c88ff", "description": "天の川"}, + {"id": 13, "name": "create", "base_cp_min": 75, "base_cp_max": 175, "color": "#00d2d3", "description": "創造"}, + {"id": 14, "name": "supernova", "base_cp_min": 100, "base_cp_max": 200, "color": "#ff9ff3", "description": "超新星"}, + {"id": 15, "name": "world", "base_cp_min": 85, "base_cp_max": 185, "color": "#54a0ff", "description": "存在と世界は同じもの"}, +] + + +async def init_db(): + """Initialize database tables and master data""" + print("Creating database tables...") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + print("Inserting master data...") + async with async_session() as session: + # Check if master data already exists + existing = await session.execute( + "SELECT COUNT(*) FROM card_master" + ) + count = existing.scalar() + + if count == 0: + # Insert card master data + for card_data in CARD_MASTER_DATA: + card = CardMaster(**card_data) + session.add(card) + + await session.commit() + print(f"Inserted {len(CARD_MASTER_DATA)} card master records") + else: + print("Master data already exists, skipping...") + + print("Database initialization complete!") + + +async def drop_db(): + """Drop all database tables""" + print("Dropping all database tables...") + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + print("All tables dropped!") + + +if __name__ == "__main__": + import sys + + if len(sys.argv) > 1 and sys.argv[1] == "drop": + asyncio.run(drop_db()) + else: + asyncio.run(init_db()) \ No newline at end of file diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..c9cdd65 --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,18 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-multipart==0.0.6 +httpx==0.25.2 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +sqlalchemy==2.0.23 +alembic==1.12.1 +asyncpg==0.29.0 +psycopg2-binary==2.9.9 +aiosqlite==0.19.0 +python-dotenv==1.0.0 +pytest==7.4.3 +pytest-asyncio==0.21.1 +atproto==0.0.46 +supabase==2.3.0 \ No newline at end of file diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..594fa1e --- /dev/null +++ b/docker-compose.production.yml @@ -0,0 +1,54 @@ +version: '3.8' + +# Production configuration with Cloudflare Tunnel +services: + api: + build: + context: ./api + dockerfile: Dockerfile + restart: unless-stopped + environment: + DATABASE_URL: ${DATABASE_URL} + DATABASE_URL_SUPABASE: ${DATABASE_URL_SUPABASE} + USE_SUPABASE: ${USE_SUPABASE:-false} + PYTHONPATH: /app + networks: + - internal + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + + web: + build: + context: ./web + dockerfile: Dockerfile + restart: unless-stopped + networks: + - internal + environment: + - VITE_API_URL=http://api:8000 + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000"] + interval: 30s + timeout: 10s + retries: 3 + + cloudflared: + image: cloudflare/cloudflared:latest + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} + networks: + - internal + depends_on: + api: + condition: service_healthy + web: + condition: service_healthy + +networks: + internal: + driver: bridge \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..671be97 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,64 @@ +version: '3.8' + +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: aicard + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 10s + timeout: 5s + retries: 5 + + api: + build: + context: ./api + dockerfile: Dockerfile + restart: unless-stopped + environment: + DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/aicard + PYTHONPATH: /app + ports: + - "8000:8000" + depends_on: + postgres: + condition: service_healthy + volumes: + - ./api:/app + command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload + + web: + build: + context: ./web + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "3000:3000" + depends_on: + - api + environment: + - VITE_API_URL=http://api:8000 + + # Cloudflare Tunnel (optional) + cloudflared: + image: cloudflare/cloudflared:latest + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN} + depends_on: + - api + - web + profiles: + - tunnel + +volumes: + postgres_data: \ No newline at end of file diff --git a/docs/AI_CONTEXT.md b/docs/AI_CONTEXT.md new file mode 100644 index 0000000..de34535 --- /dev/null +++ b/docs/AI_CONTEXT.md @@ -0,0 +1,285 @@ +# AI Context Document - ai.card プロジェクト + +> **重要**: このドキュメントは、将来のAI開発者(Claude Code等)が迅速にプロジェクトを理解し、作業を継続できるよう設計されています。 + +## 🎯 プロジェクト概要 + +**ai.card** は、atprotoベースの分散型カードゲームです。ユーザーがデータを所有し、世界で一人だけが持てるuniqueカードが存在する革新的なシステムです。 + +### 中核思想 +- **存在子理論**: 世界の最小単位(ai)の探求がテーマ +- **yui system**: 現実の個人とゲーム要素の1:1紐付け +- **データ主権**: atproto PDSでユーザーがカードデータを所有 +- **現実の反映**: ゲームがプレイヤーの現実と連動 + +## 🏗️ システム構成 + +``` +[iOS App] ←→ [Web App] ←→ [FastAPI API] ←→ [PostgreSQL] + ↕ + [atproto PDS] + ↕ + [ai.verse(将来)] +``` + +### 技術スタック(2025年6月1日現在) +- **Backend**: Python 3.11 + FastAPI + PostgreSQL + Docker +- **Frontend**: React 18 + TypeScript + Vite + Framer Motion +- **Mobile**: SwiftUI + Combine + iOS 16.0+ +- **Identity**: atproto DID + JWT +- **Infrastructure**: Docker Compose + Cloudflare Tunnel + Supabase + +## 💎 uniqueカードシステム(最重要概念) + +### 概念 +``` +通常のガチャ → キラカード(0.1%) → uniqueカード(0.0001%) + ↑表 ↑隠し機能 + ユーザーの目標 偶然の幸運 +``` + +### 実装 +- **確率**: 0.0001%(10万分の1) +- **唯一性**: カードID 0-15の各種類につき、世界で1人のみ所有可能 +- **検証**: `unique_card_registry`テーブル + atproto PDS両方でチェック +- **エフェクト**: 虹色オーラ + パーティクル + 特別UI + +### データフロー +``` +ガチャ実行 → レアリティ判定 → unique可能性チェック → +atomic操作で確保 → DB保存 → atproto PDS同期 → アニメーション表示 +``` + +## 🔐 atproto統合 + +### 認証フロー +``` +ユーザー: ハンドル + アプリパスワード + ↓ +atproto PDS認証 + ↓ +JWT発行 → セッション管理 + ↓ +API呼び出し認証 +``` + +### データ同期 +``` +カード取得 → DB保存 → atproto collection record作成 + ↓ +レキシコン: ai.card.collection + ↓ +ユーザーPDSにデータ保存(ユーザーがデータ所有) +``` + +## 📁 重要なファイル構造 + +### Backend(最重要) +``` +api/app/ +├── models/card.py # カードデータ定義 +├── services/gacha.py # ガチャロジック(uniqueカード生成) +├── services/atproto.py # atproto統合 +├── services/card_sync.py # PDS同期 +├── repositories/card.py # カードデータアクセス +├── routes/auth.py # 認証API +├── routes/cards.py # カードAPI +└── db/models.py # データベースモデル +``` + +### Frontend +``` +web/src/ +├── components/Card.tsx # カード表示(エフェクト付き) +├── components/GachaAnimation.tsx # ガチャ演出 +├── services/auth.ts # 認証管理 +└── services/api.ts # API通信 + +ios/AiCard/AiCard/ +├── Views/GachaView.swift # ガチャ画面 +├── Views/CardView.swift # カード表示 +├── Services/APIClient.swift # API通信 +└── Services/AuthManager.swift # 認証管理 +``` + +## 🎮 ゲーム仕様 + +### カードマスター(16種類) +```json +{ + "0": {"name": "アイ", "color": "fff700", "description": "世界の最小単位"}, + "1": {"name": "夢幻", "color": "b19cd9", "description": "意識が物質を作る"}, + ... + "15": {"name": "世界", "color": "54a0ff", "description": "存在と世界は同じもの"} +} +``` + +### レアリティ確率 +``` +Normal: 99.789% → グレー系 +Rare: 0.1% → ブルー系 +Super Rare: 0.01% → パープル系 +Kira: 0.1% → ゴールド系(スパークル) +Unique: 0.0001% → マゼンタ系(オーラ) +``` + +## 🚀 開発環境セットアップ + +### 1. 基本起動 +```bash +git clone [repository] +cd ai.card + +# Docker環境起動 +docker-compose up -d + +# データベース初期化 +docker-compose exec api python init_db.py + +# Web開発サーバー +cd web && npm install && npm run dev + +# iOS(Xcodeで開く) +open ios/AiCard/AiCard.xcodeproj +``` + +### 2. 環境変数設定(.env) +```bash +# PostgreSQL +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/aicard + +# atproto(テスト用) +ATPROTO_HANDLE=test.bsky.social +ATPROTO_PASSWORD=your-app-password + +# JWT +SECRET_KEY=your-secret-key +``` + +### 3. atprotoアカウント準備 +1. Blueskyアカウント作成 +2. アプリパスワード生成(https://bsky.app/settings/app-passwords) +3. 環境変数に設定 + +## 🔧 よくある実装パターン + +### 1. 新しいAPIエンドポイント追加 +```python +# 1. routes/に新しいルート定義 +@router.post("/new-endpoint") +async def new_endpoint(db: AsyncSession = Depends(get_db)): + # ロジック + +# 2. main.pyにルーター追加 +app.include_router(new_router, prefix=settings.api_v1_prefix) +``` + +### 2. データベーステーブル追加 +```python +# 1. db/models.pyに新しいモデル +class NewModel(Base): + __tablename__ = "new_table" + # フィールド定義 + +# 2. Alembicマイグレーション +alembic revision --autogenerate -m "add new table" +alembic upgrade head +``` + +### 3. atproto新機能追加 +```python +# services/atproto.pyに新しいメソッド +async def new_atproto_feature(self, did: str, data: dict): + # atproto SDK使用 + return self.client.some_new_api(data) +``` + +## 🎨 UI/UXパターン + +### カードエフェクト実装 +```typescript +// Web(React + Framer Motion) + +``` + +```swift +// iOS(SwiftUI) +CardView(card: card) + .rotation3DEffect(.degrees(isRevealing ? 0 : 180), axis: (0, 1, 0)) + .animation(.easeInOut(duration: 0.8), value: isRevealing) +``` + +## ⚠️ 重要な注意点 + +### 1. uniqueカードの整合性 +- **必須**: atomic操作でのunique確保 +- **必須**: DB + atproto PDS両方での検証 +- **注意**: レース条件の回避 + +### 2. atproto連携 +- **メインパスワード禁止**: 必ずアプリパスワード使用 +- **セッション管理**: JWTトークンの適切な管理 +- **エラーハンドリング**: atproto PDS接続失敗時の処理 + +### 3. 確率システム +- **透明性**: 確率は隠さず設定ファイルで管理 +- **公平性**: サーバーサイドでの確率計算必須 +- **監査**: ガチャ履歴の完全記録 + +## 🔮 将来の拡張ポイント + +### Phase 1: 運用安定化 +- 統合テスト自動化 +- モニタリング・アラート +- パフォーマンス最適化 + +### Phase 2: 機能拡張 +- カード交換システム +- プッシュ通知 +- リアルタイム同期 + +### Phase 3: エコシステム統合 +- ai.gpt連携(AI人格とカード連動) +- ai.verse連携(3Dゲーム世界でunique skill) +- 分散SNS連携 + +## 📋 デバッグ・トラブルシューティング + +### よくある問題 +1. **ガチャでカードが生成されない** + → `services/gacha.py`のエラーログ確認 + +2. **atproto認証失敗** + → アプリパスワードとハンドルの確認 + +3. **uniqueカード重複** + → `unique_card_registry`テーブルの整合性チェック + +4. **データベース接続失敗** + → Docker Composeの起動状態確認 + +### ログ確認 +```bash +# API ログ +docker-compose logs -f api + +# データベース状態 +docker-compose exec postgres psql -U postgres -d aicard -c "SELECT * FROM unique_card_registry;" +``` + +--- + +## 📚 推奨読み込み順序(AI向け) + +1. **このドキュメント全体** - プロジェクト概要把握 +2. **CLAUDE.md** - 哲学・思想の理解 +3. **IMPLEMENTATION_SUMMARY.md** - 具体的実装詳細 +4. **API.md** - APIエンドポイント仕様 +5. **DATABASE.md** - データベース設計 +6. **ATPROTO.md** - atproto連携詳細 + +新しいAI開発者は、この順序で読むことで迅速にプロジェクトを理解し、作業を開始できます。 \ No newline at end of file diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..abe24ba --- /dev/null +++ b/docs/API.md @@ -0,0 +1,102 @@ +# ai.card API Documentation + +## Base URL +``` +http://localhost:8000/api/v1 +``` + +## Endpoints + +### Draw Card +カードを抽選します。 + +``` +POST /cards/draw +``` + +#### Request Body +```json +{ + "user_did": "did:plc:example123", + "is_paid": false +} +``` + +#### Response +```json +{ + "card": { + "id": 0, + "cp": 88, + "status": "normal", + "skill": null, + "owner_did": "did:plc:example123", + "obtained_at": "2025-01-01T00:00:00", + "is_unique": false, + "unique_id": null + }, + "is_new": true, + "animation_type": "normal" +} +``` + +### Get User Cards +ユーザーの所有カード一覧を取得します。 + +``` +GET /cards/user/{user_did} +``` + +#### Response +```json +[ + { + "id": 0, + "cp": 88, + "status": "normal", + "skill": null, + "owner_did": "did:plc:example123", + "obtained_at": "2025-01-01T00:00:00", + "is_unique": false, + "unique_id": null + } +] +``` + +### Get Unique Cards +全てのuniqueカード一覧を取得します。 + +``` +GET /cards/unique +``` + +#### Response +```json +[ + { + "id": 8, + "cp": 500, + "status": "unique", + "skill": "skill_8_unique", + "owner_did": "did:plc:example123", + "obtained_at": "2025-01-01T00:00:00", + "is_unique": true, + "unique_id": "550e8400-e29b-41d4-a716-446655440000" + } +] +``` + +## Card Rarity + +- `normal`: 通常カード (99.789%) +- `rare`: レアカード (0.1%) +- `super_rare`: スーパーレアカード (0.01%) +- `kira`: キラカード (0.1%) +- `unique`: ユニークカード (0.0001%) + +## Animation Types + +- `normal`: 通常演出 +- `rare`: レア演出 +- `kira`: キラカード演出 +- `unique`: ユニークカード演出(特別演出) \ No newline at end of file diff --git a/docs/ATPROTO.md b/docs/ATPROTO.md new file mode 100644 index 0000000..d51083a --- /dev/null +++ b/docs/ATPROTO.md @@ -0,0 +1,146 @@ +# atproto連携ガイド + +## 概要 + +ai.cardは、atproto(AT Protocol)と完全に統合されており、以下の機能を提供します: + +1. **atproto認証**: DIDベースの分散型認証 +2. **データ主権**: カードデータをユーザーのPDSに保存 +3. **相互運用性**: 他のatproto対応アプリとの連携 + +## 認証フロー + +### 1. ログイン + +```javascript +// フロントエンド +const response = await authService.login(identifier, password); +// identifier: ハンドル(user.bsky.social)またはDID +// password: アプリパスワード(メインパスワードではない) +``` + +### 2. アプリパスワードの作成 + +1. https://bsky.app/settings/app-passwords にアクセス +2. 新しいアプリパスワードを作成 +3. ai.cardでそのパスワードを使用 + +### 3. セッション管理 + +- JWTトークンで24時間有効 +- Cookieとヘッダーの両方をサポート +- 自動更新機能なし(再ログインが必要) + +## データ保存 + +### カードコレクションのLexicon + +```json +{ + "lexicon": 1, + "id": "ai.card.collection", + "defs": { + "main": { + "type": "record", + "record": { + "type": "object", + "properties": { + "cardId": { "type": "integer" }, + "cp": { "type": "integer" }, + "status": { "type": "string" }, + "skill": { "type": "string" }, + "obtainedAt": { "type": "string" }, + "isUnique": { "type": "boolean" }, + "uniqueId": { "type": "string" } + } + } + } + } +} +``` + +### データ同期 + +```bash +# カードをPDSに同期 +POST /api/v1/sync/cards +{ + "atproto_session": "session-string-from-login" +} + +# PDSからインポート +POST /api/v1/sync/import + +# PDSにエクスポート +POST /api/v1/sync/export +``` + +## セキュリティ + +### 1. 認証情報の取り扱い + +- **メインパスワードは使用しない**: 必ずアプリパスワードを使用 +- **セッション文字列の保護**: atprotoセッションは暗号化して保存 +- **HTTPS必須**: 本番環境では必ずHTTPS経由で通信 + +### 2. データ検証 + +- サーバー側でカードデータの整合性をチェック +- uniqueカードはグローバルレジストリで重複防止 +- PDSのデータも信頼せず、常に検証 + +### 3. 権限管理 + +現在の制限: +- ユーザーはPDSのデータを自由に編集可能 +- OAuth 2.1 scope実装待ち + +対策: +- サーバー側検証で不正データを無効化 +- ゲームプレイ時は常にサーバーチェック + +## APIエンドポイント + +### 認証 + +``` +POST /api/v1/auth/login - ログイン +POST /api/v1/auth/logout - ログアウト +GET /api/v1/auth/verify - セッション確認 +POST /api/v1/auth/verify-did - DID検証(公開) +``` + +### 同期 + +``` +POST /api/v1/sync/cards - 双方向同期 +POST /api/v1/sync/export - PDSへエクスポート +POST /api/v1/sync/import - PDSからインポート +GET /api/v1/sync/verify/:id - カード所有確認 +``` + +## トラブルシューティング + +### ログインできない + +1. アプリパスワードを使用しているか確認 +2. ハンドルまたはDIDが正しいか確認 +3. PDSが稼働しているか確認 + +### データが同期されない + +1. atprotoセッションが有効か確認 +2. PDSの容量制限を確認 +3. ネットワーク接続を確認 + +### カードが表示されない + +1. `/api/v1/sync/import`でPDSからインポート +2. ブラウザキャッシュをクリア +3. 再ログイン + +## 今後の予定 + +1. **OAuth 2.1対応**: より細かい権限管理 +2. **リアルタイム同期**: WebSocketでの即時反映 +3. **他アプリ連携**: atprotoエコシステムとの統合 \ No newline at end of file diff --git a/docs/DATABASE.md b/docs/DATABASE.md new file mode 100644 index 0000000..c9e79bc --- /dev/null +++ b/docs/DATABASE.md @@ -0,0 +1,102 @@ +# データベース設定ガイド + +## ローカル開発(Docker Compose) + +### 1. 起動 +```bash +# データベースとAPIを起動 +docker-compose up -d + +# ログを確認 +docker-compose logs -f +``` + +### 2. データベース初期化 +```bash +# APIコンテナに入る +docker-compose exec api bash + +# マイグレーション実行 +alembic upgrade head + +# マスタデータ投入 +python init_db.py +``` + +## Supabase連携 + +### 1. Supabaseプロジェクト作成 +1. [Supabase](https://supabase.com)でプロジェクト作成 +2. Settings > Database から接続情報を取得 + +### 2. 環境変数設定 +```bash +# .env +DATABASE_URL_SUPABASE=postgresql+asyncpg://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgres +USE_SUPABASE=true +``` + +### 3. テーブル作成 +Supabase SQL Editorで以下を実行: + +```sql +-- Alembicのマイグレーションを実行 +-- または直接SQLでテーブル作成 +``` + +## Cloudflare Tunnel設定 + +### 1. トンネル作成 +```bash +# Cloudflareダッシュボードでトンネル作成 +# トークンを取得 +``` + +### 2. 環境変数設定 +```bash +# .env +CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token +``` + +### 3. 起動 +```bash +# tunnelプロファイルを含めて起動 +docker-compose --profile tunnel up -d +``` + +## データベーススキーマ + +### users +- ユーザー情報(DID、ハンドル) + +### card_master +- カードマスタデータ(16種類) + +### user_cards +- ユーザー所有カード +- uniqueカードフラグ付き + +### unique_card_registry +- グローバルuniqueカード登録 +- 各カードIDにつき1人のみ所有可能 + +### draw_history +- ガチャ履歴 + +### gacha_pools +- ピックアップガチャ設定 + +## バックアップ + +### ローカル +```bash +# バックアップ +docker-compose exec postgres pg_dump -U postgres aicard > backup.sql + +# リストア +docker-compose exec -T postgres psql -U postgres aicard < backup.sql +``` + +### Supabase +- 自動バックアップが有効 +- ダッシュボードからダウンロード可能 \ No newline at end of file diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..7f46283 --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,124 @@ +# 開発ガイド + +## セットアップ + +### 1. API (FastAPI) + +```bash +cd api + +# 仮想環境作成 +python -m venv venv +source venv/bin/activate # macOS/Linux +# or +venv\Scripts\activate # Windows + +# 依存関係インストール +pip install -r requirements.txt + +# 環境変数設定 +cp .env.example .env +# .envを編集 + +# 開発サーバー起動 +uvicorn app.main:app --reload +``` + +APIは http://localhost:8000 で起動します。 +APIドキュメントは http://localhost:8000/docs で確認できます。 + +### 2. Web (React + Vite) + +```bash +cd web + +# 依存関係インストール +npm install + +# 開発サーバー起動 +npm run dev +``` + +Webアプリは http://localhost:3000 で起動します。 + +## プロジェクト構造 + +``` +ai.card/ +├── api/ # FastAPI backend +│ ├── app/ +│ │ ├── core/ # 設定、共通処理 +│ │ ├── models/ # Pydanticモデル +│ │ ├── routes/ # APIエンドポイント +│ │ ├── services/ # ビジネスロジック +│ │ └── main.py # アプリケーションエントリ +│ └── requirements.txt +│ +├── web/ # React frontend +│ ├── src/ +│ │ ├── components/ # Reactコンポーネント +│ │ ├── services/ # API通信 +│ │ ├── styles/ # CSS +│ │ ├── types/ # TypeScript型定義 +│ │ └── App.tsx # メインコンポーネント +│ └── package.json +│ +├── ios/ # iOS app (今後実装) +└── docs/ # ドキュメント +``` + +## 技術スタック + +### Backend +- Python 3.9+ +- FastAPI +- Pydantic +- SQLAlchemy (今後実装) +- atproto SDK + +### Frontend +- React 18 +- TypeScript +- Vite +- Framer Motion (アニメーション) +- Axios + +## 開発のポイント + +### 1. カードデータ +カードは0-15のIDを持ち、ai.jsonの定義に基づいています。 + +### 2. レアリティシステム +- 通常のガチャではキラカードが最高レア +- uniqueカードは隠し要素として実装 +- 確率は設定ファイルで調整可能 + +### 3. atproto連携 +- ユーザー認証はatproto OAuth(今後実装) +- カードデータはユーザーのPDSに保存(今後実装) +- 現在はローカルストレージのみ + +### 4. アニメーション +- ガチャ演出はレアリティに応じて変化 +- uniqueカードは特別な演出 +- Framer Motionで実装 + +## 今後の実装予定 + +1. **データベース連携** + - SQLAlchemyでのモデル定義 + - ユーザーごとのカード管理 + +2. **atproto統合** + - OAuth認証 + - PDSへのデータ保存 + - DID検証 + +3. **uniqueカード検証** + - グローバルレジストリ + - 重複チェック + - ai.verse連携 + +4. **iOS app** + - SwiftUIで実装 + - 共通APIを使用 \ No newline at end of file diff --git a/docs/IMPLEMENTATION_SUMMARY.md b/docs/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..5e9a3fe --- /dev/null +++ b/docs/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,267 @@ +# ai.card 実装完了サマリー + +## 作業日: 2025年6月1日 + +### 📋 今日実装した内容 + +## 1. データベース実装(PostgreSQL + Supabase) + +### 完成機能 +- **PostgreSQLスキーマ設計**: 7つのテーブル(users, card_master, user_cards, unique_card_registry等) +- **Docker Compose環境**: 開発・本番両対応 +- **Supabase連携**: 環境変数で切り替え可能 +- **リポジトリパターン**: BaseRepository + 専用Repository +- **マイグレーション**: Alembic設定 + 初期データ投入 +- **データ同期**: ガチャ時の自動データベース保存 + +### 重要ファイル +``` +api/app/db/models.py # SQLAlchemyモデル +api/app/repositories/ # リポジトリパターン実装 +api/init_db.py # データベース初期化 +docker-compose.yml # 開発環境 +docker-compose.production.yml # 本番環境 +``` + +## 2. atproto連携機能 + +### 完成機能 +- **認証システム**: DID/ハンドルログイン + JWTトークン +- **PDSデータ保存**: カードをユーザーのPDSに自動同期 +- **Lexicon定義**: `ai.card.collection` スキーマ +- **同期API**: 双方向同期・インポート・エクスポート +- **データ検証**: サーバー側整合性チェック + +### 重要ファイル +``` +api/app/services/atproto.py # atproto統合サービス +api/app/services/card_sync.py # カード同期サービス +api/app/routes/auth.py # 認証API +api/app/routes/sync.py # 同期API +api/app/auth/dependencies.py # 認証依存関係 +``` + +## 3. iOS App完全実装 + +### 完成機能 +- **SwiftUI + MVVM**: Combineを使ったリアクティブアーキテクチャ +- **認証画面**: atprotoログイン(アプリパスワード対応) +- **ガチャシステム**: 通常・プレミアムガチャ + リッチアニメーション +- **カードコレクション**: グリッド表示・検索・フィルタ・詳細画面 +- **プロフィール**: ユーザー情報・統計・設定 +- **視覚エフェクト**: レアリティ別アニメーション・3Dフリップ + +### 重要ファイル +``` +ios/AiCard/AiCard/ +├── Models/Card.swift # カードデータモデル +├── Services/APIClient.swift # API通信(Combine使用) +├── Services/AuthManager.swift # 認証管理 +├── Services/CardManager.swift # カード管理 +├── Views/LoginView.swift # ログイン画面 +├── Views/GachaView.swift # ガチャ画面 +├── Views/CollectionView.swift # コレクション画面 +├── Views/CardView.swift # カード表示コンポーネント +└── Views/GachaAnimationView.swift # ガチャアニメーション +``` + +## 4. プロジェクト統合 + +### アーキテクチャ概要 +``` +[iOS App] ←→ [Web App] ←→ [FastAPI] ←→ [PostgreSQL] + ↕ + [atproto PDS] +``` + +### データフロー +1. **ガチャ**: iOS/Web → API → DB保存 → atproto PDS同期 +2. **認証**: atproto DID → JWT → セッション管理 +3. **同期**: DB ↔ atproto PDS双方向同期 + +## 📊 実装済み機能一覧 + +### ✅ Backend (FastAPI) +- [x] PostgreSQL + Supabase対応 +- [x] atproto認証・同期 +- [x] ガチャシステム(確率・unique管理) +- [x] カードCRUD API +- [x] Docker環境(開発・本番) +- [x] リポジトリパターン +- [x] データベースマイグレーション + +### ✅ Frontend (React) +- [x] atproto認証UI +- [x] ガチャアニメーション(Framer Motion) +- [x] カード表示・コレクション +- [x] レスポンシブデザイン +- [x] TypeScript対応 + +### ✅ Mobile (iOS) +- [x] SwiftUI + MVVM + Combine +- [x] atproto認証 +- [x] ガチャ(通常・プレミアム) +- [x] カードコレクション(検索・フィルタ) +- [x] リッチアニメーション +- [x] iOS 16.0+ 対応 + +### ✅ DevOps +- [x] Docker Compose +- [x] Cloudflare Tunnel対応 +- [x] 環境別設定 +- [x] ヘルスチェック + +## 🔧 技術スタック + +### Backend +- **Language**: Python 3.11 +- **Framework**: FastAPI 0.104.1 +- **Database**: PostgreSQL + SQLAlchemy +- **ORM**: SQLAlchemy 2.0 (async) +- **Migration**: Alembic +- **Cloud**: Supabase対応 +- **atproto**: atproto SDK 0.0.46 + +### Frontend (Web) +- **Language**: TypeScript +- **Framework**: React 18 + Vite +- **Animation**: Framer Motion +- **HTTP**: Axios +- **Styling**: CSS Modules + +### Mobile (iOS) +- **Language**: Swift 5.7+ +- **Framework**: SwiftUI +- **Architecture**: MVVM + Combine +- **HTTP**: URLSession +- **Minimum**: iOS 16.0 + +### Infrastructure +- **Container**: Docker + Docker Compose +- **Proxy**: Nginx +- **Tunnel**: Cloudflare Tunnel +- **Database**: PostgreSQL 16 + +## 🎯 unique カードシステムの実装 + +### 概念 +- **確率**: 0.0001%(10万分の1) +- **唯一性**: 各カードID(0-15)につき世界で1人のみ所有可能 +- **検証**: サーバー側 + atproto PDS両方でチェック +- **将来**: ai.verse unique skillとの連携予定 + +### 実装詳細 +- `unique_card_registry`テーブルでグローバル管理 +- ガチャ時にatomic操作で重複防止 +- atproto PDSにも同期保存 +- Web/iOSで特別なエフェクト表示 + +## 🚀 デプロイメント準備 + +### 開発環境起動 +```bash +# 全体起動 +docker-compose up -d + +# データベース初期化 +docker-compose exec api python init_db.py + +# Web開発サーバー +cd web && npm run dev + +# iOS(Xcodeで開く) +open ios/AiCard/AiCard.xcodeproj +``` + +### 本番環境起動 +```bash +# 本番設定で起動 +docker-compose -f docker-compose.production.yml up -d +``` + +## 📝 重要な設定ファイル + +### 環境変数(.env) +```bash +# Database +DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/aicard +DATABASE_URL_SUPABASE=postgresql+asyncpg://... +USE_SUPABASE=false + +# atproto +ATPROTO_HANDLE=your.bsky.social +ATPROTO_PASSWORD=your-app-password + +# Security +SECRET_KEY=your-secret-key + +# Cloudflare Tunnel +CLOUDFLARE_TUNNEL_TOKEN=your-tunnel-token +``` + +### API設定 +- **開発**: `http://localhost:8000` +- **本番**: `https://api.card.syui.ai` +- **認証**: Bearer JWT token +- **CORS**: Web/iOS対応 + +## 🔮 今後の実装候補 + +### Phase 1: 運用準備 +- [ ] 統合テスト(全システム連携) +- [ ] パフォーマンス最適化 +- [ ] モニタリング・ログ +- [ ] セキュリティ監査 + +### Phase 2: 機能拡張 +- [ ] カード交換システム +- [ ] プッシュ通知(iOS) +- [ ] リアルタイム同期(WebSocket) +- [ ] バックアップ・復元 + +### Phase 3: エコシステム統合 +- [ ] ai.gpt連携 +- [ ] ai.verse unique skill連携 +- [ ] yui system実装 +- [ ] 分散SNS連携 + +## 🎮 ゲーム仕様 + +### カードシステム +- **種類**: 16種類(ai, 夢幻, 光彩, 中性子, 太陽, 夜空, 雪, 雷, 超究, 剣, 破壊, 地球, 天の川, 創造, 超新星, 世界) +- **CP**: 1-999(レアリティでボーナス) +- **レアリティ**: 5段階(normal, rare, super_rare, kira, unique) + +### ガチャ確率 +- **Normal**: 99.789% +- **Rare**: 0.1% +- **Super Rare**: 0.01% +- **Kira**: 0.1% +- **Unique**: 0.0001%(隠し機能) + +### 演出 +- **Web**: CSS + Framer Motion +- **iOS**: SwiftUI Animation + Particle Effects +- **レアリティ別**: 色・エフェクト・音(予定) + +--- + +## 💡 AI向けメモ + +### プロジェクト理解のキーポイント +1. **存在子理論**: 最小単位の意識がゲーム世界の根幹 +2. **yui system**: 現実の個人とゲーム内要素の1:1紐付け +3. **データ主権**: atproto PDSでユーザーがデータを所有 +4. **uniqueカード**: NFT的だがブロックチェーン不使用 + +### 重要な実装パターン +- **リポジトリパターン**: データアクセス層の抽象化 +- **atproto同期**: ガチャ時の自動PDS保存 +- **レアリティシステム**: 確率とエフェクトの連動 +- **認証フロー**: DID → JWT → セッション管理 + +### 次回作業時の注意点 +- 環境変数の設定確認 +- データベースの初期化 +- atprotoアカウントの準備 +- Docker環境の起動確認 \ No newline at end of file diff --git a/ios/AiCard/AiCard.xcodeproj/project.pbxproj b/ios/AiCard/AiCard.xcodeproj/project.pbxproj new file mode 100644 index 0000000..4e5247b --- /dev/null +++ b/ios/AiCard/AiCard.xcodeproj/project.pbxproj @@ -0,0 +1,330 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 56; + objects = { + +/* Begin PBXBuildFile section */ + 1A1234561234567890ABCDEF /* AiCardApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1234551234567890ABCDEF /* AiCardApp.swift */; }; + 1A1234581234567890ABCDEF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A1234571234567890ABCDEF /* ContentView.swift */; }; + 1A12345A1234567890ABCDEF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1A1234591234567890ABCDEF /* Assets.xcassets */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 1A1234521234567890ABCDEF /* AiCard.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AiCard.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1A1234551234567890ABCDEF /* AiCardApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AiCardApp.swift; sourceTree = ""; }; + 1A1234571234567890ABCDEF /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 1A1234591234567890ABCDEF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1A12344F1234567890ABCDEF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1A1234491234567890ABCDEF = { + isa = PBXGroup; + children = ( + 1A1234541234567890ABCDEF /* AiCard */, + 1A1234531234567890ABCDEF /* Products */, + ); + sourceTree = ""; + }; + 1A1234531234567890ABCDEF /* Products */ = { + isa = PBXGroup; + children = ( + 1A1234521234567890ABCDEF /* AiCard.app */, + ); + name = Products; + sourceTree = ""; + }; + 1A1234541234567890ABCDEF /* AiCard */ = { + isa = PBXGroup; + children = ( + 1A1234551234567890ABCDEF /* AiCardApp.swift */, + 1A1234571234567890ABCDEF /* ContentView.swift */, + 1A1234591234567890ABCDEF /* Assets.xcassets */, + ); + path = AiCard; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1A1234511234567890ABCDEF /* AiCard */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A1234601234567890ABCDEF /* Build configuration list for PBXNativeTarget "AiCard" */; + buildPhases = ( + 1A12344E1234567890ABCDEF /* Sources */, + 1A12344F1234567890ABCDEF /* Frameworks */, + 1A1234501234567890ABCDEF /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = AiCard; + productName = AiCard; + productReference = 1A1234521234567890ABCDEF /* AiCard.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1A12344A1234567890ABCDEF /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1500; + TargetAttributes = { + 1A1234511234567890ABCDEF = { + CreatedOnToolsVersion = 15.0; + }; + }; + }; + buildConfigurationList = 1A12344D1234567890ABCDEF /* Build configuration list for PBXProject "AiCard" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1A1234491234567890ABCDEF; + productRefGroup = 1A1234531234567890ABCDEF /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1A1234511234567890ABCDEF /* AiCard */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1A1234501234567890ABCDEF /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A12345A1234567890ABCDEF /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1A12344E1234567890ABCDEF /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1A1234581234567890ABCDEF /* ContentView.swift in Sources */, + 1A1234561234567890ABCDEF /* AiCardApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1A12345E1234567890ABCDEF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 1A12345F1234567890ABCDEF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 1A1234611234567890ABCDEF /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ai.syui.card; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1A1234621234567890ABCDEF /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_ASSET_PATHS = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = ai.syui.card; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1A12344D1234567890ABCDEF /* Build configuration list for PBXProject "AiCard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A12345E1234567890ABCDEF /* Debug */, + 1A12345F1234567890ABCDEF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A1234601234567890ABCDEF /* Build configuration list for PBXNativeTarget "AiCard" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A1234611234567890ABCDEF /* Debug */, + 1A1234621234567890ABCDEF /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1A12344A1234567890ABCDEF /* Project object */; +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/AiCardApp.swift b/ios/AiCard/AiCard/AiCardApp.swift new file mode 100644 index 0000000..665d8c5 --- /dev/null +++ b/ios/AiCard/AiCard/AiCardApp.swift @@ -0,0 +1,16 @@ +import SwiftUI + +@main +struct AiCardApp: App { + @StateObject private var authManager = AuthManager() + @StateObject private var cardManager = CardManager() + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(authManager) + .environmentObject(cardManager) + .preferredColorScheme(.dark) + } + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/ContentView.swift b/ios/AiCard/AiCard/ContentView.swift new file mode 100644 index 0000000..1a3a6b9 --- /dev/null +++ b/ios/AiCard/AiCard/ContentView.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct ContentView: View { + @EnvironmentObject var authManager: AuthManager + @State private var selectedTab = 0 + + var body: some View { + if authManager.isAuthenticated { + TabView(selection: $selectedTab) { + GachaView() + .tabItem { + Label("ガチャ", systemImage: "sparkles") + } + .tag(0) + + CollectionView() + .tabItem { + Label("コレクション", systemImage: "square.grid.3x3") + } + .tag(1) + + ProfileView() + .tabItem { + Label("プロフィール", systemImage: "person.circle") + } + .tag(2) + } + .accentColor(Color(hex: "fff700")) + } else { + LoginView() + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + .environmentObject(AuthManager()) + .environmentObject(CardManager()) + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Models/Card.swift b/ios/AiCard/AiCard/Models/Card.swift new file mode 100644 index 0000000..906d631 --- /dev/null +++ b/ios/AiCard/AiCard/Models/Card.swift @@ -0,0 +1,90 @@ +import Foundation + +enum CardRarity: String, Codable, CaseIterable { + case normal = "normal" + case rare = "rare" + case superRare = "super_rare" + case kira = "kira" + case unique = "unique" + + var displayName: String { + switch self { + case .normal: return "ノーマル" + case .rare: return "レア" + case .superRare: return "スーパーレア" + case .kira: return "キラ" + case .unique: return "ユニーク" + } + } + + var gradientColors: [String] { + switch self { + case .normal: return ["666666", "333333"] + case .rare: return ["4a90e2", "16213e"] + case .superRare: return ["9c27b0", "0f0c29"] + case .kira: return ["ffd700", "414345"] + case .unique: return ["ff00ff", "1a0033"] + } + } +} + +struct Card: Identifiable, Codable { + let id: Int + let cp: Int + let status: CardRarity + let skill: String? + let ownerDid: String + let obtainedAt: Date + let isUnique: Bool + let uniqueId: String? + + private enum CodingKeys: String, CodingKey { + case id + case cp + case status + case skill + case ownerDid = "owner_did" + case obtainedAt = "obtained_at" + case isUnique = "is_unique" + case uniqueId = "unique_id" + } +} + +struct CardDrawResult: Codable { + let card: Card + let isNew: Bool + let animationType: String + + private enum CodingKeys: String, CodingKey { + case card + case isNew = "is_new" + case animationType = "animation_type" + } +} + +// Card master data +struct CardInfo { + let id: Int + let name: String + let color: String + let description: String + + static let all: [Int: CardInfo] = [ + 0: CardInfo(id: 0, name: "アイ", color: "fff700", description: "世界の最小単位"), + 1: CardInfo(id: 1, name: "夢幻", color: "b19cd9", description: "意識が物質を作る"), + 2: CardInfo(id: 2, name: "光彩", color: "ffd700", description: "存在は光に向かう"), + 3: CardInfo(id: 3, name: "中性子", color: "cacfd2", description: "中性子"), + 4: CardInfo(id: 4, name: "太陽", color: "ff6b35", description: "太陽"), + 5: CardInfo(id: 5, name: "夜空", color: "1a1a2e", description: "夜空"), + 6: CardInfo(id: 6, name: "雪", color: "e3f2fd", description: "雪"), + 7: CardInfo(id: 7, name: "雷", color: "ffd93d", description: "雷"), + 8: CardInfo(id: 8, name: "超究", color: "6c5ce7", description: "超究"), + 9: CardInfo(id: 9, name: "剣", color: "a8e6cf", description: "剣"), + 10: CardInfo(id: 10, name: "破壊", color: "ff4757", description: "破壊"), + 11: CardInfo(id: 11, name: "地球", color: "4834d4", description: "地球"), + 12: CardInfo(id: 12, name: "天の川", color: "9c88ff", description: "天の川"), + 13: CardInfo(id: 13, name: "創造", color: "00d2d3", description: "創造"), + 14: CardInfo(id: 14, name: "超新星", color: "ff9ff3", description: "超新星"), + 15: CardInfo(id: 15, name: "世界", color: "54a0ff", description: "存在と世界は同じもの") + ] +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Models/User.swift b/ios/AiCard/AiCard/Models/User.swift new file mode 100644 index 0000000..869cb28 --- /dev/null +++ b/ios/AiCard/AiCard/Models/User.swift @@ -0,0 +1,25 @@ +import Foundation + +struct User: Codable { + let did: String + let handle: String +} + +struct LoginRequest: Codable { + let identifier: String + let password: String +} + +struct LoginResponse: Codable { + let accessToken: String + let tokenType: String + let did: String + let handle: String + + private enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + case did + case handle + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Services/APIClient.swift b/ios/AiCard/AiCard/Services/APIClient.swift new file mode 100644 index 0000000..a6f67e6 --- /dev/null +++ b/ios/AiCard/AiCard/Services/APIClient.swift @@ -0,0 +1,125 @@ +import Foundation +import Combine + +enum APIError: Error { + case invalidURL + case noData + case decodingError + case networkError(String) + case unauthorized +} + +class APIClient { + static let shared = APIClient() + + #if DEBUG + private let baseURL = "http://localhost:8000/api/v1" + #else + private let baseURL = "https://api.card.syui.ai/api/v1" + #endif + + private var cancellables = Set() + + private init() {} + + private var authToken: String? { + get { UserDefaults.standard.string(forKey: "authToken") } + set { UserDefaults.standard.set(newValue, forKey: "authToken") } + } + + private func request(_ endpoint: String, + method: String = "GET", + body: Data? = nil, + authenticated: Bool = true) -> AnyPublisher { + guard let url = URL(string: "\(baseURL)\(endpoint)") else { + return Fail(error: APIError.invalidURL).eraseToAnyPublisher() + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + if authenticated, let token = authToken { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + + if let body = body { + request.httpBody = body + } + + return URLSession.shared.dataTaskPublisher(for: request) + .tryMap { data, response in + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.networkError("Invalid response") + } + + if httpResponse.statusCode == 401 { + throw APIError.unauthorized + } + + if !(200...299).contains(httpResponse.statusCode) { + throw APIError.networkError("Status code: \(httpResponse.statusCode)") + } + + return data + } + .decode(type: T.self, decoder: JSONDecoder()) + .mapError { error in + if error is DecodingError { + return APIError.decodingError + } else if let apiError = error as? APIError { + return apiError + } else { + return APIError.networkError(error.localizedDescription) + } + } + .eraseToAnyPublisher() + } + + // MARK: - Auth + + func login(identifier: String, password: String) -> AnyPublisher { + let loginRequest = LoginRequest(identifier: identifier, password: password) + guard let body = try? JSONEncoder().encode(loginRequest) else { + return Fail(error: APIError.decodingError).eraseToAnyPublisher() + } + + return request("/auth/login", method: "POST", body: body, authenticated: false) + .handleEvents(receiveOutput: { [weak self] (response: LoginResponse) in + self?.authToken = response.accessToken + }) + .eraseToAnyPublisher() + } + + func logout() -> AnyPublisher { + request("/auth/logout", method: "POST") + .map { (_: [String: String]) in () } + .handleEvents(receiveCompletion: { [weak self] _ in + self?.authToken = nil + }) + .eraseToAnyPublisher() + } + + func verify() -> AnyPublisher { + request("/auth/verify") + } + + // MARK: - Cards + + func drawCard(userDid: String, isPaid: Bool = false) -> AnyPublisher { + let body = try? JSONEncoder().encode([ + "user_did": userDid, + "is_paid": isPaid + ]) + + return request("/cards/draw", method: "POST", body: body) + } + + func getUserCards(userDid: String) -> AnyPublisher<[Card], APIError> { + request("/cards/user/\(userDid)") + } + + func getUniqueCards() -> AnyPublisher<[[String: Any]], APIError> { + request("/cards/unique") + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Services/AuthManager.swift b/ios/AiCard/AiCard/Services/AuthManager.swift new file mode 100644 index 0000000..6b0281a --- /dev/null +++ b/ios/AiCard/AiCard/Services/AuthManager.swift @@ -0,0 +1,87 @@ +import Foundation +import Combine +import SwiftUI + +class AuthManager: ObservableObject { + @Published var isAuthenticated = false + @Published var currentUser: User? + @Published var isLoading = false + @Published var errorMessage: String? + + private var cancellables = Set() + private let apiClient = APIClient.shared + + init() { + checkAuthStatus() + } + + private func checkAuthStatus() { + isLoading = true + + apiClient.verify() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoading = false + if case .failure = completion { + self?.isAuthenticated = false + self?.currentUser = nil + } + }, + receiveValue: { [weak self] user in + self?.isAuthenticated = true + self?.currentUser = user + } + ) + .store(in: &cancellables) + } + + func login(identifier: String, password: String) { + isLoading = true + errorMessage = nil + + apiClient.login(identifier: identifier, password: password) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoading = false + if case .failure(let error) = completion { + self?.errorMessage = self?.getErrorMessage(from: error) + } + }, + receiveValue: { [weak self] response in + self?.isAuthenticated = true + self?.currentUser = User(did: response.did, handle: response.handle) + } + ) + .store(in: &cancellables) + } + + func logout() { + isLoading = true + + apiClient.logout() + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] _ in + self?.isLoading = false + self?.isAuthenticated = false + self?.currentUser = nil + UserDefaults.standard.removeObject(forKey: "authToken") + }, + receiveValue: { _ in } + ) + .store(in: &cancellables) + } + + private func getErrorMessage(from error: APIError) -> String { + switch error { + case .unauthorized: + return "認証情報が正しくありません" + case .networkError: + return "ネットワークエラーが発生しました" + default: + return "エラーが発生しました" + } + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Services/CardManager.swift b/ios/AiCard/AiCard/Services/CardManager.swift new file mode 100644 index 0000000..4712691 --- /dev/null +++ b/ios/AiCard/AiCard/Services/CardManager.swift @@ -0,0 +1,73 @@ +import Foundation +import Combine +import SwiftUI + +class CardManager: ObservableObject { + @Published var userCards: [Card] = [] + @Published var isLoading = false + @Published var errorMessage: String? + @Published var currentDraw: CardDrawResult? + @Published var isDrawing = false + + private var cancellables = Set() + private let apiClient = APIClient.shared + + func loadUserCards(userDid: String) { + isLoading = true + + apiClient.getUserCards(userDid: userDid) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + self?.isLoading = false + if case .failure(let error) = completion { + self?.errorMessage = self?.getErrorMessage(from: error) + } + }, + receiveValue: { [weak self] cards in + self?.userCards = cards + } + ) + .store(in: &cancellables) + } + + func drawCard(userDid: String, isPaid: Bool = false) { + isDrawing = true + errorMessage = nil + + apiClient.drawCard(userDid: userDid, isPaid: isPaid) + .receive(on: DispatchQueue.main) + .sink( + receiveCompletion: { [weak self] completion in + if case .failure(let error) = completion { + self?.isDrawing = false + self?.errorMessage = self?.getErrorMessage(from: error) + } + }, + receiveValue: { [weak self] result in + self?.currentDraw = result + // アニメーション終了後にカードを追加 + } + ) + .store(in: &cancellables) + } + + func completeCardDraw() { + if let newCard = currentDraw?.card { + userCards.append(newCard) + } + currentDraw = nil + isDrawing = false + } + + private func getErrorMessage(from error: APIError) -> String { + switch error { + case .unauthorized: + return "認証が必要です" + case .networkError: + return "ネットワークエラーが発生しました" + default: + return "エラーが発生しました" + } + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Utils/Color+Extensions.swift b/ios/AiCard/AiCard/Utils/Color+Extensions.swift new file mode 100644 index 0000000..68edc89 --- /dev/null +++ b/ios/AiCard/AiCard/Utils/Color+Extensions.swift @@ -0,0 +1,28 @@ +import SwiftUI + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: // RGB (12-bit) + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: // RGB (24-bit) + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: // ARGB (32-bit) + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Views/CardView.swift b/ios/AiCard/AiCard/Views/CardView.swift new file mode 100644 index 0000000..d6b13a7 --- /dev/null +++ b/ios/AiCard/AiCard/Views/CardView.swift @@ -0,0 +1,247 @@ +import SwiftUI + +struct CardView: View { + let card: Card + let isRevealing: Bool + @State private var isFlipped = false + + init(card: Card, isRevealing: Bool = false) { + self.card = card + self.isRevealing = isRevealing + } + + var body: some View { + ZStack { + // Card background + RoundedRectangle(cornerRadius: 16) + .fill( + LinearGradient( + gradient: Gradient(colors: gradientColors), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 200, height: 280) + + // Card content + VStack(spacing: 16) { + // Header + HStack { + Text("#\(card.id)") + .font(.caption) + .foregroundColor(.white.opacity(0.7)) + + Spacer() + + Text("CP: \(card.cp)") + .font(.caption) + .foregroundColor(.white.opacity(0.7)) + } + + Spacer() + + // Card name and icon + VStack(spacing: 12) { + // Card icon (could be an image) + Circle() + .fill(Color(hex: cardInfo.color)) + .frame(width: 60, height: 60) + .overlay( + Text(cardInfo.name.prefix(1)) + .font(.title) + .fontWeight(.bold) + .foregroundColor(.white) + ) + + Text(cardInfo.name) + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + .multilineTextAlignment(.center) + + if card.isUnique { + UniqueBadge() + } + } + + Spacer() + + // Skill + if let skill = card.skill, !skill.isEmpty { + Text(skill) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.black.opacity(0.3)) + .cornerRadius(8) + } + + // Rarity + Text(card.status.displayName) + .font(.caption) + .foregroundColor(.white.opacity(0.7)) + .textCase(.uppercase) + .tracking(1) + } + .padding(20) + + // Special effects + if card.status == .kira { + KiraEffect() + } else if card.status == .unique { + UniqueEffect() + } + } + .rotation3DEffect( + .degrees(isRevealing && !isFlipped ? 180 : 0), + axis: (x: 0, y: 1, z: 0) + ) + .onAppear { + if isRevealing { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + withAnimation(.easeInOut(duration: 0.8)) { + isFlipped = true + } + } + } + } + .scaleEffect(isRevealing ? 1.1 : 1.0) + .shadow(color: .black.opacity(0.3), radius: 10, x: 0, y: 5) + } + + private var cardInfo: CardInfo { + CardInfo.all[card.id] ?? CardInfo(id: card.id, name: "Unknown", color: "666666", description: "") + } + + private var gradientColors: [Color] { + card.status.gradientColors.map { Color(hex: $0) } + } +} + +struct UniqueBadge: View { + @State private var phase: CGFloat = 0 + + var body: some View { + Text("UNIQUE") + .font(.caption2) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .background( + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "ff00ff"), + Color(hex: "00ffff") + ]), + startPoint: .leading, + endPoint: .trailing + ) + .hueRotation(.degrees(phase)) + ) + .cornerRadius(12) + .onAppear { + withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) { + phase = 360 + } + } + } +} + +struct KiraEffect: View { + @State private var sparkles: [SparkleData] = [] + + var body: some View { + ZStack { + ForEach(sparkles, id: \.id) { sparkle in + Image(systemName: "sparkle") + .foregroundColor(.yellow) + .font(.system(size: sparkle.size)) + .position(x: sparkle.x, y: sparkle.y) + .opacity(sparkle.opacity) + } + } + .onAppear { + generateSparkles() + } + } + + private func generateSparkles() { + for i in 0..<10 { + let sparkle = SparkleData( + id: i, + x: CGFloat.random(in: 20...180), + y: CGFloat.random(in: 20...260), + size: CGFloat.random(in: 8...16), + opacity: Double.random(in: 0.3...0.8) + ) + sparkles.append(sparkle) + + DispatchQueue.main.asyncAfter(deadline: .now() + Double.random(in: 0...2)) { + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + if let index = sparkles.firstIndex(where: { $0.id == sparkle.id }) { + sparkles[index].opacity = sparkles[index].opacity > 0.5 ? 0.2 : 0.8 + } + } + } + } + } +} + +struct UniqueEffect: View { + @State private var pulseScale: CGFloat = 1.0 + + var body: some View { + RoundedRectangle(cornerRadius: 16) + .stroke( + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "ff00ff"), + Color(hex: "00ffff"), + Color(hex: "ff00ff") + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + lineWidth: 3 + ) + .scaleEffect(pulseScale) + .opacity(0.8) + .onAppear { + withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { + pulseScale = 1.05 + } + } + } +} + +struct SparkleData { + let id: Int + let x: CGFloat + let y: CGFloat + let size: CGFloat + var opacity: Double +} + +struct CardView_Previews: PreviewProvider { + static var previews: some View { + let sampleCard = Card( + id: 0, + cp: 100, + status: .unique, + skill: "サンプルスキル", + ownerDid: "did:plc:example", + obtainedAt: Date(), + isUnique: true, + uniqueId: "unique-123" + ) + + VStack { + CardView(card: sampleCard) + CardView(card: sampleCard, isRevealing: true) + } + .padding() + .background(Color.black) + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Views/CollectionView.swift b/ios/AiCard/AiCard/Views/CollectionView.swift new file mode 100644 index 0000000..588b6ac --- /dev/null +++ b/ios/AiCard/AiCard/Views/CollectionView.swift @@ -0,0 +1,341 @@ +import SwiftUI + +struct CollectionView: View { + @EnvironmentObject var authManager: AuthManager + @EnvironmentObject var cardManager: CardManager + @State private var selectedCard: Card? + @State private var searchText = "" + @State private var selectedRarity: CardRarity? + @State private var showingFilters = false + + var filteredCards: [Card] { + var cards = cardManager.userCards + + // Search filter + if !searchText.isEmpty { + cards = cards.filter { card in + let cardInfo = CardInfo.all[card.id] + return cardInfo?.name.localizedCaseInsensitiveContains(searchText) ?? false + } + } + + // Rarity filter + if let selectedRarity = selectedRarity { + cards = cards.filter { $0.status == selectedRarity } + } + + return cards.sorted { $0.obtainedAt > $1.obtainedAt } + } + + var body: some View { + NavigationView { + ZStack { + // Background + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "0a0a0a"), + Color(hex: "1a1a1a") + ]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 0) { + // Search and filter bar + VStack(spacing: 12) { + HStack { + // Search field + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + + TextField("カードを検索...", text: $searchText) + .textFieldStyle(PlainTextFieldStyle()) + + if !searchText.isEmpty { + Button(action: { + searchText = "" + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.secondary) + } + } + } + .padding(12) + .background(Color.white.opacity(0.1)) + .cornerRadius(12) + + // Filter button + Button(action: { + showingFilters.toggle() + }) { + Image(systemName: "line.3.horizontal.decrease.circle") + .font(.title2) + .foregroundColor(Color(hex: "fff700")) + } + } + + // Filter chips + if showingFilters { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + FilterChip( + title: "すべて", + isSelected: selectedRarity == nil + ) { + selectedRarity = nil + } + + ForEach(CardRarity.allCases, id: \.self) { rarity in + FilterChip( + title: rarity.displayName, + isSelected: selectedRarity == rarity + ) { + selectedRarity = selectedRarity == rarity ? nil : rarity + } + } + } + .padding(.horizontal) + } + } + } + .padding(.horizontal) + .padding(.top) + + // Collection stats + CollectionStatsView(cards: cardManager.userCards) + .padding(.horizontal) + .padding(.vertical, 8) + + // Card grid + if cardManager.isLoading { + Spacer() + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: Color(hex: "fff700"))) + Spacer() + } else if filteredCards.isEmpty { + Spacer() + EmptyCollectionView(hasCards: !cardManager.userCards.isEmpty) + Spacer() + } else { + ScrollView { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ], + spacing: 20 + ) { + ForEach(filteredCards) { card in + CardView(card: card) + .scaleEffect(0.8) + .onTapGesture { + selectedCard = card + } + } + } + .padding(.horizontal) + .padding(.bottom, 100) + } + } + } + } + .navigationTitle("コレクション") + .navigationBarTitleDisplayMode(.large) + .onAppear { + if let userDid = authManager.currentUser?.did { + cardManager.loadUserCards(userDid: userDid) + } + } + .sheet(item: $selectedCard) { card in + CardDetailView(card: card) + } + } + } +} + +struct FilterChip: View { + let title: String + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.caption) + .fontWeight(isSelected ? .bold : .medium) + .foregroundColor(isSelected ? .black : .white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + isSelected + ? Color(hex: "fff700") + : Color.white.opacity(0.1) + ) + .cornerRadius(16) + } + } +} + +struct CollectionStatsView: View { + let cards: [Card] + + private var stats: (total: Int, unique: Int, completion: Double) { + let total = cards.count + let uniqueCards = Set(cards.map { $0.id }).count + let completion = Double(uniqueCards) / 16.0 * 100 + + return (total, uniqueCards, completion) + } + + var body: some View { + HStack(spacing: 20) { + StatItem(title: "総枚数", value: "\(stats.total)") + StatItem(title: "種類", value: "\(stats.unique)/16") + StatItem(title: "完成度", value: String(format: "%.1f%%", stats.completion)) + } + .padding(.vertical, 12) + .padding(.horizontal, 16) + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + } +} + +struct StatItem: View { + let title: String + let value: String + + var body: some View { + VStack(spacing: 4) { + Text(value) + .font(.headline) + .fontWeight(.bold) + .foregroundColor(Color(hex: "fff700")) + + Text(title) + .font(.caption) + .foregroundColor(.secondary) + } + } +} + +struct EmptyCollectionView: View { + let hasCards: Bool + + var body: some View { + VStack(spacing: 16) { + Image(systemName: hasCards ? "magnifyingglass" : "square.stack.3d.up") + .font(.system(size: 48)) + .foregroundColor(.secondary) + + Text(hasCards ? "検索結果がありません" : "カードがありません") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.white) + + Text(hasCards ? "検索条件を変更してください" : "ガチャでカードを引いてみましょう") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .padding() + } +} + +struct CardDetailView: View { + let card: Card + @Environment(\.presentationMode) var presentationMode + + private var cardInfo: CardInfo { + CardInfo.all[card.id] ?? CardInfo(id: card.id, name: "Unknown", color: "666666", description: "") + } + + var body: some View { + NavigationView { + ZStack { + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "0a0a0a"), + Color(hex: "1a1a1a") + ]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + // Card display + CardView(card: card) + .scaleEffect(1.2) + .padding(.top, 20) + + // Card details + VStack(alignment: .leading, spacing: 16) { + DetailRow(title: "ID", value: "#\(card.id)") + DetailRow(title: "名前", value: cardInfo.name) + DetailRow(title: "CP", value: "\(card.cp)") + DetailRow(title: "レアリティ", value: card.status.displayName) + + if let skill = card.skill, !skill.isEmpty { + DetailRow(title: "スキル", value: skill) + } + + if card.isUnique, let uniqueId = card.uniqueId { + DetailRow(title: "ユニークID", value: uniqueId) + } + + DetailRow( + title: "取得日時", + value: DateFormatter.localizedString( + from: card.obtainedAt, + dateStyle: .medium, + timeStyle: .short + ) + ) + } + .padding(.horizontal) + } + .padding(.bottom, 100) + } + } + .navigationTitle(cardInfo.name) + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems( + trailing: Button("閉じる") { + presentationMode.wrappedValue.dismiss() + } + ) + } + } +} + +struct DetailRow: View { + let title: String + let value: String + + var body: some View { + HStack { + Text(title) + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Text(value) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.white) + } + .padding(.vertical, 4) + } +} + +struct CollectionView_Previews: PreviewProvider { + static var previews: some View { + CollectionView() + .environmentObject(AuthManager()) + .environmentObject(CardManager()) + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Views/GachaAnimationView.swift b/ios/AiCard/AiCard/Views/GachaAnimationView.swift new file mode 100644 index 0000000..8673fd4 --- /dev/null +++ b/ios/AiCard/AiCard/Views/GachaAnimationView.swift @@ -0,0 +1,310 @@ +import SwiftUI + +struct GachaAnimationView: View { + let drawResult: CardDrawResult + let onComplete: () -> Void + + @State private var phase: AnimationPhase = .opening + @State private var packScale: CGFloat = 0 + @State private var packOpacity: Double = 0 + @State private var cardScale: CGFloat = 0 + @State private var cardOpacity: Double = 0 + @State private var showCard = false + @State private var effectOpacity: Double = 0 + + enum AnimationPhase { + case opening + case revealing + case complete + } + + var body: some View { + ZStack { + // Dark overlay + Rectangle() + .fill(Color.black.opacity(0.9)) + .ignoresSafeArea() + .onTapGesture { + if phase == .complete { + onComplete() + } + } + + // Background effects based on card rarity + if phase != .opening { + backgroundEffect + .opacity(effectOpacity) + } + + // Pack animation + if phase == .opening { + GachaPackView() + .scaleEffect(packScale) + .opacity(packOpacity) + } + + // Card reveal + if showCard { + CardView(card: drawResult.card, isRevealing: true) + .scaleEffect(cardScale) + .opacity(cardOpacity) + } + + // Complete state overlay + if phase == .complete { + VStack { + Spacer() + + VStack(spacing: 16) { + if drawResult.isNew { + Text("新しいカードを獲得!") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(Color(hex: "fff700")) + } + + Text("タップして続ける") + .font(.caption) + .foregroundColor(.white.opacity(0.7)) + } + .padding(.bottom, 50) + } + } + } + .onAppear { + startAnimation() + } + } + + private var backgroundEffect: some View { + Group { + switch drawResult.animationType { + case "unique": + UniqueBackgroundEffect() + case "kira": + KiraBackgroundEffect() + case "rare": + RareBackgroundEffect() + default: + EmptyView() + } + } + } + + private func startAnimation() { + // Phase 1: Pack appears + withAnimation(.spring(response: 0.6, dampingFraction: 0.8)) { + packScale = 1.0 + packOpacity = 1.0 + } + + // Phase 2: Pack disappears, card appears + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation(.easeOut(duration: 0.3)) { + packOpacity = 0 + } + + phase = .revealing + showCard = true + + withAnimation(.spring(response: 0.8, dampingFraction: 0.6)) { + cardScale = 1.0 + cardOpacity = 1.0 + effectOpacity = 1.0 + } + } + + // Phase 3: Animation complete + DispatchQueue.main.asyncAfter(deadline: .now() + 3.5) { + phase = .complete + } + } +} + +struct GachaPackView: View { + @State private var glowIntensity: Double = 0.5 + + var body: some View { + ZStack { + // Pack background + RoundedRectangle(cornerRadius: 20) + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "667eea"), + Color(hex: "764ba2") + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 150, height: 200) + + // Pack glow + RoundedRectangle(cornerRadius: 20) + .stroke(Color.white, lineWidth: 2) + .frame(width: 150, height: 200) + .blur(radius: 10) + .opacity(glowIntensity) + + // Pack label + VStack { + Image(systemName: "sparkles") + .font(.title) + .foregroundColor(.white) + + Text("ai.card") + .font(.headline) + .fontWeight(.bold) + .foregroundColor(.white) + } + } + .onAppear { + withAnimation(.easeInOut(duration: 1).repeatForever(autoreverses: true)) { + glowIntensity = 1.0 + } + } + } +} + +struct UniqueBackgroundEffect: View { + @State private var particles: [ParticleData] = [] + @State private var burstScale: CGFloat = 0 + + var body: some View { + ZStack { + // Radial burst + Circle() + .fill( + RadialGradient( + gradient: Gradient(colors: [ + Color(hex: "ff00ff").opacity(0.8), + Color.clear + ]), + center: .center, + startRadius: 0, + endRadius: 200 + ) + ) + .scaleEffect(burstScale) + .onAppear { + withAnimation(.easeOut(duration: 1)) { + burstScale = 3 + } + } + + // Floating particles + ForEach(particles, id: \.id) { particle in + Circle() + .fill(Color(hex: particle.color)) + .frame(width: particle.size, height: particle.size) + .position(x: particle.x, y: particle.y) + .opacity(particle.opacity) + } + } + .onAppear { + generateParticles() + } + } + + private func generateParticles() { + for i in 0..<20 { + let particle = ParticleData( + id: i, + x: CGFloat.random(in: 0...UIScreen.main.bounds.width), + y: CGFloat.random(in: 0...UIScreen.main.bounds.height), + size: CGFloat.random(in: 4...12), + color: ["ff00ff", "00ffff", "ffffff"].randomElement() ?? "ffffff", + opacity: Double.random(in: 0.3...0.8) + ) + particles.append(particle) + } + } +} + +struct KiraBackgroundEffect: View { + @State private var sparkleOffset: CGFloat = -100 + + var body: some View { + ZStack { + ForEach(0..<5, id: \.self) { i in + Rectangle() + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color.clear, + Color.yellow.opacity(0.3), + Color.clear + ]), + startPoint: .leading, + endPoint: .trailing + ) + ) + .frame(width: 2, height: UIScreen.main.bounds.height) + .rotationEffect(.degrees(45)) + .offset(x: sparkleOffset + CGFloat(i * 50)) + } + } + .onAppear { + withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) { + sparkleOffset = UIScreen.main.bounds.width + 100 + } + } + } +} + +struct RareBackgroundEffect: View { + @State private var rippleScale: CGFloat = 0 + + var body: some View { + ZStack { + ForEach(0..<3, id: \.self) { i in + Circle() + .stroke(Color.blue.opacity(0.3), lineWidth: 2) + .scaleEffect(rippleScale) + .opacity(1 - rippleScale) + .animation( + .easeOut(duration: 2) + .delay(Double(i) * 0.3) + .repeatForever(autoreverses: false), + value: rippleScale + ) + } + } + .onAppear { + rippleScale = 3 + } + } +} + +struct ParticleData { + let id: Int + let x: CGFloat + let y: CGFloat + let size: CGFloat + let color: String + let opacity: Double +} + +struct GachaAnimationView_Previews: PreviewProvider { + static var previews: some View { + let sampleResult = CardDrawResult( + card: Card( + id: 0, + cp: 500, + status: .unique, + skill: "サンプルスキル", + ownerDid: "did:plc:example", + obtainedAt: Date(), + isUnique: true, + uniqueId: "unique-123" + ), + isNew: true, + animationType: "unique" + ) + + GachaAnimationView(drawResult: sampleResult) { + print("Animation complete") + } + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Views/GachaView.swift b/ios/AiCard/AiCard/Views/GachaView.swift new file mode 100644 index 0000000..e93fea7 --- /dev/null +++ b/ios/AiCard/AiCard/Views/GachaView.swift @@ -0,0 +1,190 @@ +import SwiftUI + +struct GachaView: View { + @EnvironmentObject var authManager: AuthManager + @EnvironmentObject var cardManager: CardManager + @State private var showingAnimation = false + + var body: some View { + ZStack { + // Background + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "0a0a0a"), + Color(hex: "1a1a1a") + ]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 40) { + // Title + VStack(spacing: 16) { + Text("カードを引く") + .font(.largeTitle) + .fontWeight(.bold) + .foregroundColor(.white) + + if let user = authManager.currentUser { + Text("@\(user.handle)") + .font(.subheadline) + .foregroundColor(Color(hex: "fff700")) + } + } + + Spacer() + + // Gacha buttons + VStack(spacing: 20) { + GachaButton( + title: "通常ガチャ", + subtitle: "無料でカードを1枚引く", + colors: [Color(hex: "667eea"), Color(hex: "764ba2")], + action: { + drawCard(isPaid: false) + }, + isLoading: cardManager.isDrawing + ) + + GachaButton( + title: "プレミアムガチャ", + subtitle: "レア確率アップ!", + colors: [Color(hex: "f093fb"), Color(hex: "f5576c")], + action: { + drawCard(isPaid: true) + }, + isLoading: cardManager.isDrawing, + isPremium: true + ) + } + .padding(.horizontal, 32) + + if let errorMessage = cardManager.errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + .padding() + } + + Spacer() + } + .padding() + + // Gacha animation overlay + if let currentDraw = cardManager.currentDraw { + GachaAnimationView( + drawResult: currentDraw, + onComplete: { + cardManager.completeCardDraw() + } + ) + .transition(.opacity) + .zIndex(1000) + } + } + .onAppear { + if let userDid = authManager.currentUser?.did { + cardManager.loadUserCards(userDid: userDid) + } + } + } + + private func drawCard(isPaid: Bool) { + guard let userDid = authManager.currentUser?.did else { return } + cardManager.drawCard(userDid: userDid, isPaid: isPaid) + } +} + +struct GachaButton: View { + let title: String + let subtitle: String + let colors: [Color] + let action: () -> Void + let isLoading: Bool + let isPremium: Bool + + init(title: String, subtitle: String, colors: [Color], action: @escaping () -> Void, isLoading: Bool, isPremium: Bool = false) { + self.title = title + self.subtitle = subtitle + self.colors = colors + self.action = action + self.isLoading = isLoading + self.isPremium = isPremium + } + + var body: some View { + Button(action: action) { + VStack(spacing: 8) { + Text(title) + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + + Text(subtitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + } + .frame(maxWidth: .infinity) + .frame(height: 80) + .background( + ZStack { + LinearGradient( + gradient: Gradient(colors: colors), + startPoint: .leading, + endPoint: .trailing + ) + + if isPremium { + // Shimmer effect for premium + ShimmerView() + } + } + ) + .cornerRadius(16) + .shadow(color: colors.first?.opacity(0.3) ?? .clear, radius: 10, x: 0, y: 5) + .overlay( + Group { + if isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } + } + ) + } + .disabled(isLoading) + .scaleEffect(isLoading ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.1), value: isLoading) + } +} + +struct ShimmerView: View { + @State private var phase: CGFloat = 0 + + var body: some View { + LinearGradient( + gradient: Gradient(colors: [ + .clear, + .white.opacity(0.2), + .clear + ]), + startPoint: .leading, + endPoint: .trailing + ) + .rotationEffect(.degrees(45)) + .offset(x: phase) + .onAppear { + withAnimation(.linear(duration: 2).repeatForever(autoreverses: false)) { + phase = 300 + } + } + } +} + +struct GachaView_Previews: PreviewProvider { + static var previews: some View { + GachaView() + .environmentObject(AuthManager()) + .environmentObject(CardManager()) + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Views/LoginView.swift b/ios/AiCard/AiCard/Views/LoginView.swift new file mode 100644 index 0000000..7d2ba38 --- /dev/null +++ b/ios/AiCard/AiCard/Views/LoginView.swift @@ -0,0 +1,157 @@ +import SwiftUI + +struct LoginView: View { + @EnvironmentObject var authManager: AuthManager + @State private var identifier = "" + @State private var password = "" + @State private var showingPassword = false + + var body: some View { + ZStack { + // Background + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "0a0a0a"), + Color(hex: "1a1a1a") + ]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + VStack(spacing: 40) { + // Logo and title + VStack(spacing: 20) { + Text("ai.card") + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundStyle( + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "fff700"), + Color(hex: "ff00ff") + ]), + startPoint: .leading, + endPoint: .trailing + ) + ) + + Text("atprotoベースカードゲーム") + .font(.title3) + .foregroundColor(.secondary) + } + + // Login form + VStack(spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text("ハンドル または DID") + .font(.caption) + .foregroundColor(.secondary) + + TextField("your.bsky.social", text: $identifier) + .textFieldStyle(CustomTextFieldStyle()) + .autocapitalization(.none) + .disableAutocorrection(true) + } + + VStack(alignment: .leading, spacing: 8) { + Text("アプリパスワード") + .font(.caption) + .foregroundColor(.secondary) + + HStack { + if showingPassword { + TextField("アプリパスワード", text: $password) + } else { + SecureField("アプリパスワード", text: $password) + } + + Button(action: { + showingPassword.toggle() + }) { + Image(systemName: showingPassword ? "eye.slash" : "eye") + .foregroundColor(.secondary) + } + } + .textFieldStyle(CustomTextFieldStyle()) + + Text("メインパスワードではなく、アプリパスワードを使用してください") + .font(.caption2) + .foregroundColor(.secondary) + } + + if let errorMessage = authManager.errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundColor(.red) + .padding(.horizontal) + } + + Button(action: { + authManager.login(identifier: identifier, password: password) + }) { + HStack { + if authManager.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .black)) + .scaleEffect(0.8) + } + + Text(authManager.isLoading ? "ログイン中..." : "ログイン") + .font(.headline) + .foregroundColor(.black) + } + .frame(maxWidth: .infinity) + .frame(height: 50) + .background( + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "fff700"), + Color(hex: "ffd700") + ]), + startPoint: .leading, + endPoint: .trailing + ) + ) + .cornerRadius(12) + } + .disabled(authManager.isLoading || identifier.isEmpty || password.isEmpty) + .opacity(authManager.isLoading || identifier.isEmpty || password.isEmpty ? 0.6 : 1.0) + } + .padding(.horizontal, 32) + + Spacer() + + VStack(spacing: 12) { + Text("ai.cardはatprotoアカウントを使用します") + .font(.caption) + .foregroundColor(.secondary) + + Text("データはあなたのPDSに保存されます") + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding() + } + } +} + +struct CustomTextFieldStyle: TextFieldStyle { + func _body(configuration: TextField) -> some View { + configuration + .padding() + .background(Color.white.opacity(0.1)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.white.opacity(0.2), lineWidth: 1) + ) + } +} + +struct LoginView_Previews: PreviewProvider { + static var previews: some View { + LoginView() + .environmentObject(AuthManager()) + } +} \ No newline at end of file diff --git a/ios/AiCard/AiCard/Views/ProfileView.swift b/ios/AiCard/AiCard/Views/ProfileView.swift new file mode 100644 index 0000000..9fdf52e --- /dev/null +++ b/ios/AiCard/AiCard/Views/ProfileView.swift @@ -0,0 +1,265 @@ +import SwiftUI + +struct ProfileView: View { + @EnvironmentObject var authManager: AuthManager + @EnvironmentObject var cardManager: CardManager + @State private var showingLogoutAlert = false + + var body: some View { + NavigationView { + ZStack { + // Background + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "0a0a0a"), + Color(hex: "1a1a1a") + ]), + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 24) { + // Profile header + if let user = authManager.currentUser { + ProfileHeaderView(user: user) + } + + // Collection summary + CollectionSummaryView(cards: cardManager.userCards) + + // Menu items + VStack(spacing: 1) { + MenuRow( + icon: "arrow.triangle.2.circlepath", + title: "データ同期", + subtitle: "atproto PDSと同期" + ) { + // TODO: Implement sync + } + + MenuRow( + icon: "crown", + title: "ユニークカード", + subtitle: "所有しているユニークカード" + ) { + // TODO: Show unique cards + } + + MenuRow( + icon: "info.circle", + title: "アプリについて", + subtitle: "バージョン情報" + ) { + // TODO: Show about + } + } + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + + Spacer(minLength: 40) + + // Logout button + Button(action: { + showingLogoutAlert = true + }) { + HStack { + Image(systemName: "rectangle.portrait.and.arrow.right") + Text("ログアウト") + } + .font(.headline) + .foregroundColor(.red) + .frame(maxWidth: .infinity) + .frame(height: 50) + .background(Color.red.opacity(0.1)) + .cornerRadius(12) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.red.opacity(0.3), lineWidth: 1) + ) + } + + Spacer(minLength: 100) + } + .padding(.horizontal) + } + } + .navigationTitle("プロフィール") + .navigationBarTitleDisplayMode(.large) + .alert("ログアウト", isPresented: $showingLogoutAlert) { + Button("キャンセル", role: .cancel) { } + Button("ログアウト", role: .destructive) { + authManager.logout() + } + } message: { + Text("ログアウトしますか?") + } + } + } +} + +struct ProfileHeaderView: View { + let user: User + + var body: some View { + VStack(spacing: 16) { + // Avatar + Circle() + .fill( + LinearGradient( + gradient: Gradient(colors: [ + Color(hex: "fff700"), + Color(hex: "ff00ff") + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .frame(width: 80, height: 80) + .overlay( + Text(user.handle.prefix(1).uppercased()) + .font(.title) + .fontWeight(.bold) + .foregroundColor(.black) + ) + + // User info + VStack(spacing: 4) { + Text("@\(user.handle)") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.white) + + Text(user.did) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + .padding(.vertical, 20) + } +} + +struct CollectionSummaryView: View { + let cards: [Card] + + private var summary: (total: Int, unique: Int, rarest: CardRarity?) { + let total = cards.count + let uniqueCount = cards.filter { $0.isUnique }.count + let rarest = cards.map { $0.status }.max { lhs, rhs in + rarityOrder(lhs) < rarityOrder(rhs) + } + + return (total, uniqueCount, rarest) + } + + private func rarityOrder(_ rarity: CardRarity) -> Int { + switch rarity { + case .normal: return 0 + case .rare: return 1 + case .superRare: return 2 + case .kira: return 3 + case .unique: return 4 + } + } + + var body: some View { + VStack(spacing: 16) { + Text("コレクション統計") + .font(.headline) + .foregroundColor(.white) + + HStack(spacing: 20) { + SummaryItem( + title: "総カード数", + value: "\(summary.total)", + color: Color(hex: "fff700") + ) + + SummaryItem( + title: "ユニーク", + value: "\(summary.unique)", + color: Color(hex: "ff00ff") + ) + + if let rarest = summary.rarest { + SummaryItem( + title: "最高レア", + value: rarest.displayName, + color: Color(hex: rarest.gradientColors.first ?? "ffffff") + ) + } + } + } + .padding() + .background(Color.white.opacity(0.05)) + .cornerRadius(12) + } +} + +struct SummaryItem: View { + let title: String + let value: String + let color: Color + + var body: some View { + VStack(spacing: 8) { + Text(value) + .font(.title2) + .fontWeight(.bold) + .foregroundColor(color) + + Text(title) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity) + } +} + +struct MenuRow: View { + let icon: String + let title: String + let subtitle: String + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title2) + .foregroundColor(Color(hex: "fff700")) + .frame(width: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.headline) + .foregroundColor(.white) + + Text(subtitle) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + } + .buttonStyle(PlainButtonStyle()) + } +} + +struct ProfileView_Previews: PreviewProvider { + static var previews: some View { + ProfileView() + .environmentObject(AuthManager()) + .environmentObject(CardManager()) + } +} \ No newline at end of file diff --git a/ios/Package.swift b/ios/Package.swift new file mode 100644 index 0000000..9721659 --- /dev/null +++ b/ios/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.7 +import PackageDescription + +let package = Package( + name: "AiCard", + platforms: [ + .iOS(.v16) + ], + products: [ + .library( + name: "AiCard", + targets: ["AiCard"] + ), + ], + dependencies: [ + // SwiftUI is included by default + ], + targets: [ + .target( + name: "AiCard", + dependencies: [] + ), + .testTarget( + name: "AiCardTests", + dependencies: ["AiCard"] + ), + ] +) \ No newline at end of file diff --git a/ios/README.md b/ios/README.md new file mode 100644 index 0000000..d2e26e0 --- /dev/null +++ b/ios/README.md @@ -0,0 +1,121 @@ +# ai.card iOS App + +atprotoベースのカードゲーム「ai.card」のiOSアプリです。 + +## 特徴 + +- **atproto統合**: 分散型認証とデータ主権 +- **リッチなアニメーション**: ガチャの迫力ある演出 +- **カードコレクション**: 美しいカード表示とフィルタリング +- **ユニークカードシステム**: 世界で一人だけが所有できるカード + +## アーキテクチャ + +### MVVM + Combine + +``` +Views/ +├── LoginView # atprotoログイン +├── GachaView # ガチャ画面 +├── CollectionView # コレクション画面 +├── ProfileView # プロフィール画面 +├── CardView # カード表示コンポーネント +└── GachaAnimationView # ガチャアニメーション + +Services/ +├── APIClient # REST API通信 +├── AuthManager # 認証管理 +└── CardManager # カード管理 + +Models/ +├── Card # カードデータモデル +└── User # ユーザーデータモデル +``` + +### 技術スタック + +- **UI**: SwiftUI +- **データフロー**: Combine +- **ネットワーク**: URLSession +- **認証**: atproto (JWT) +- **最小対応OS**: iOS 16.0 + +## セットアップ + +### 1. Xcodeプロジェクトを開く + +```bash +cd ios/AiCard +open AiCard.xcodeproj +``` + +### 2. API設定 + +開発環境では自動的に `localhost:8000` に接続します。 +本番環境では `api.card.syui.ai` に接続します。 + +### 3. ビルド & 実行 + +- シミュレーターまたは実機でビルド +- atprotoアカウントでログイン +- ガチャを引いてカードを集める + +## 主要機能 + +### 認証 + +- atproto DIDベース認証 +- アプリパスワード使用 +- 自動セッション管理 + +### ガチャシステム + +- 通常ガチャ(無料) +- プレミアムガチャ(確率アップ) +- レアリティ別アニメーション +- ユニークカード対応 + +### カードコレクション + +- グリッド表示 +- 検索機能 +- レアリティフィルタ +- 詳細表示 + +### プロフィール + +- ユーザー情報表示 +- コレクション統計 +- データ同期機能 + +## カードシステム + +### レアリティ + +- **ノーマル**: 基本カード +- **レア**: 少し珍しいカード +- **スーパーレア**: とても珍しいカード +- **キラ**: 光る演出付きカード(0.1%) +- **ユニーク**: 世界で一人だけ(0.0001%) + +### 視覚効果 + +- レアリティ別グラデーション +- キラカードのスパークル効果 +- ユニークカードのオーラ効果 +- 3Dフリップアニメーション + +## 今後の実装予定 + +- [ ] Push通知 +- [ ] カード交換機能 +- [ ] AI.verse連携 +- [ ] ダークモード対応 +- [ ] iPad最適化 +- [ ] ウィジェット対応 + +## 注意事項 + +- iOS 16.0以上が必要 +- atprotoアカウントが必要 +- インターネット接続が必要 \ No newline at end of file diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..87ad073 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +echo "🎴 ai.card セットアップスクリプト" +echo "================================" + +# APIセットアップ +echo "📦 API セットアップ中..." +cd api +python -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +echo "✅ API セットアップ完了" + +# Webセットアップ +echo "📦 Web セットアップ中..." +cd ../web +npm install +echo "✅ Web セットアップ完了" + +echo "================================" +echo "🚀 セットアップ完了!" +echo "" +echo "開発サーバーの起動:" +echo " API: cd api && uvicorn app.main:app --reload" +echo " Web: cd web && npm run dev" \ No newline at end of file diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..b558784 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,26 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm ci + +# Copy source files +COPY . . + +# Build application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built files +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 3000 + +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7652c5a --- /dev/null +++ b/web/index.html @@ -0,0 +1,20 @@ + + + + + + ai.card + + + +
+ + + \ No newline at end of file diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..c44ff58 --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,19 @@ +server { + listen 3000; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://api:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..3392524 --- /dev/null +++ b/web/package.json @@ -0,0 +1,23 @@ +{ + "name": "ai-card-web", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0", + "axios": "^1.6.2", + "framer-motion": "^10.16.16" + }, + "devDependencies": { + "@types/react": "^18.2.45", + "@types/react-dom": "^18.2.18", + "@vitejs/plugin-react": "^4.2.1", + "vite": "^5.0.10", + "typescript": "^5.3.3" + } +} \ No newline at end of file diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000..99275e3 --- /dev/null +++ b/web/src/App.css @@ -0,0 +1,174 @@ +.app { + min-height: 100vh; + background: linear-gradient(180deg, #0a0a0a 0%, #1a1a1a 100%); +} + +.app-header { + text-align: center; + padding: 40px 20px; + border-bottom: 1px solid #333; +} + +.app-header h1 { + font-size: 48px; + margin: 0; + background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.app-header p { + color: #888; + margin-top: 10px; +} + +.user-info { + position: absolute; + top: 20px; + right: 20px; + display: flex; + align-items: center; + gap: 15px; +} + +.user-handle { + color: #fff700; + font-weight: bold; +} + +.login-button, +.logout-button { + padding: 8px 20px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; +} + +.login-button { + background: linear-gradient(135deg, #fff700 0%, #ffd700 100%); + color: #000; +} + +.logout-button { + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid #444; +} + +.login-button:hover, +.logout-button:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.loading { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + font-size: 24px; + color: #fff700; +} + +.app-main { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +.gacha-section { + text-align: center; + margin-bottom: 60px; +} + +.gacha-section h2 { + font-size: 32px; + margin-bottom: 30px; +} + +.gacha-buttons { + display: flex; + gap: 20px; + justify-content: center; + flex-wrap: wrap; +} + +.gacha-button { + padding: 20px 40px; + font-size: 18px; + font-weight: bold; + border: none; + border-radius: 12px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + cursor: pointer; + transition: all 0.3s ease; + box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3); +} + +.gacha-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4); +} + +.gacha-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.gacha-button-premium { + background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + position: relative; + overflow: hidden; +} + +.gacha-button-premium::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient( + 45deg, + transparent 30%, + rgba(255, 255, 255, 0.2) 50%, + transparent 70% + ); + animation: shimmer 3s infinite; +} + +.collection-section h2 { + font-size: 32px; + text-align: center; + margin-bottom: 30px; +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 30px; + justify-items: center; +} + +.empty-message { + text-align: center; + color: #666; + font-size: 18px; + margin-top: 40px; +} + +.error { + color: #ff4757; + text-align: center; + margin-top: 20px; +} + +@keyframes shimmer { + 0% { transform: translateX(-100%) rotate(45deg); } + 100% { transform: translateX(100%) rotate(45deg); } +} \ No newline at end of file diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..803afb5 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,161 @@ +import React, { useState, useEffect } from 'react'; +import { Card } from './components/Card'; +import { GachaAnimation } from './components/GachaAnimation'; +import { Login } from './components/Login'; +import { cardApi } from './services/api'; +import { authService, User } from './services/auth'; +import { Card as CardType, CardDrawResult } from './types/card'; +import './App.css'; + +function App() { + const [isDrawing, setIsDrawing] = useState(false); + const [currentDraw, setCurrentDraw] = useState(null); + const [userCards, setUserCards] = useState([]); + const [error, setError] = useState(null); + const [user, setUser] = useState(null); + const [showLogin, setShowLogin] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Check if user is logged in + authService.verify().then(verifiedUser => { + if (verifiedUser) { + setUser(verifiedUser); + loadUserCards(verifiedUser.did); + } + setIsLoading(false); + }); + }, []); + + const loadUserCards = async (did: string) => { + try { + const cards = await cardApi.getUserCards(did); + setUserCards(cards); + } catch (err) { + console.error('Failed to load cards:', err); + } + }; + + const handleLogin = (did: string, handle: string) => { + setUser({ did, handle }); + setShowLogin(false); + loadUserCards(did); + }; + + const handleLogout = async () => { + await authService.logout(); + setUser(null); + setUserCards([]); + }; + + const handleDraw = async (isPaid: boolean = false) => { + if (!user) { + setShowLogin(true); + return; + } + + setIsDrawing(true); + setError(null); + + try { + const result = await cardApi.drawCard(user.did, isPaid); + setCurrentDraw(result); + } catch (err) { + setError('カードの抽選に失敗しました'); + setIsDrawing(false); + } + }; + + const handleAnimationComplete = () => { + if (currentDraw) { + setUserCards([...userCards, currentDraw.card]); + setCurrentDraw(null); + setIsDrawing(false); + } + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+

ai.card

+

atprotoベースカードゲーム

+
+ {user ? ( + <> + @{user.handle} + + + ) : ( + + )} +
+
+ +
+
+

カードを引く

+
+ + +
+ {error &&

{error}

} +
+ +
+

コレクション

+
+ {userCards.map((card, index) => ( + + ))} +
+ {userCards.length === 0 && ( +

+ {user ? 'まだカードを持っていません' : 'ログインしてカードを集めよう'} +

+ )} +
+
+ + {currentDraw && ( + + )} + + {showLogin && ( + setShowLogin(false)} + /> + )} +
+ ); +} + +export default App; \ No newline at end of file diff --git a/web/src/components/Card.tsx b/web/src/components/Card.tsx new file mode 100644 index 0000000..667493f --- /dev/null +++ b/web/src/components/Card.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { motion } from 'framer-motion'; +import { Card as CardType, CardRarity } from '../types/card'; +import '../styles/Card.css'; + +interface CardProps { + card: CardType; + isRevealing?: boolean; +} + +const CARD_INFO: Record = { + 0: { name: "アイ", color: "#fff700" }, + 1: { name: "夢幻", color: "#b19cd9" }, + 2: { name: "光彩", color: "#ffd700" }, + 3: { name: "中性子", color: "#cacfd2" }, + 4: { name: "太陽", color: "#ff6b35" }, + 5: { name: "夜空", color: "#1a1a2e" }, + 6: { name: "雪", color: "#e3f2fd" }, + 7: { name: "雷", color: "#ffd93d" }, + 8: { name: "超究", color: "#6c5ce7" }, + 9: { name: "剣", color: "#a8e6cf" }, + 10: { name: "破壊", color: "#ff4757" }, + 11: { name: "地球", color: "#4834d4" }, + 12: { name: "天の川", color: "#9c88ff" }, + 13: { name: "創造", color: "#00d2d3" }, + 14: { name: "超新星", color: "#ff9ff3" }, + 15: { name: "世界", color: "#54a0ff" }, +}; + +export const Card: React.FC = ({ card, isRevealing = false }) => { + const cardInfo = CARD_INFO[card.id] || { name: "Unknown", color: "#666" }; + + const getRarityClass = () => { + switch (card.status) { + case CardRarity.UNIQUE: + return 'card-unique'; + case CardRarity.KIRA: + return 'card-kira'; + case CardRarity.SUPER_RARE: + return 'card-super-rare'; + case CardRarity.RARE: + return 'card-rare'; + default: + return 'card-normal'; + } + }; + + return ( + +
+
+ #{card.id} + CP: {card.cp} +
+ +
+

{cardInfo.name}

+ {card.is_unique && ( +
UNIQUE
+ )} +
+ + {card.skill && ( +
+

{card.skill}

+
+ )} + +
+ {card.status.toUpperCase()} +
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/components/GachaAnimation.tsx b/web/src/components/GachaAnimation.tsx new file mode 100644 index 0000000..10187a8 --- /dev/null +++ b/web/src/components/GachaAnimation.tsx @@ -0,0 +1,84 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Card } from './Card'; +import { Card as CardType } from '../types/card'; +import '../styles/GachaAnimation.css'; + +interface GachaAnimationProps { + card: CardType; + animationType: string; + onComplete: () => void; +} + +export const GachaAnimation: React.FC = ({ + card, + animationType, + onComplete +}) => { + const [phase, setPhase] = useState<'opening' | 'revealing' | 'complete'>('opening'); + + useEffect(() => { + const timer1 = setTimeout(() => setPhase('revealing'), 1500); + const timer2 = setTimeout(() => { + setPhase('complete'); + onComplete(); + }, 3000); + + return () => { + clearTimeout(timer1); + clearTimeout(timer2); + }; + }, [onComplete]); + + const getEffectClass = () => { + switch (animationType) { + case 'unique': + return 'effect-unique'; + case 'kira': + return 'effect-kira'; + case 'rare': + return 'effect-rare'; + default: + return 'effect-normal'; + } + }; + + return ( +
+ + {phase === 'opening' && ( + +
+
+
+ + )} + + {phase === 'revealing' && ( + + + + )} + + + {animationType === 'unique' && ( +
+
+
+
+ )} +
+ ); +}; \ No newline at end of file diff --git a/web/src/components/Login.tsx b/web/src/components/Login.tsx new file mode 100644 index 0000000..66cfd31 --- /dev/null +++ b/web/src/components/Login.tsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react'; +import { motion } from 'framer-motion'; +import { authService } from '../services/auth'; +import '../styles/Login.css'; + +interface LoginProps { + onLogin: (did: string, handle: string) => void; + onClose: () => void; +} + +export const Login: React.FC = ({ onLogin, onClose }) => { + const [identifier, setIdentifier] = useState(''); + const [password, setPassword] = useState(''); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + const response = await authService.login(identifier, password); + onLogin(response.did, response.handle); + } catch (err) { + setError('ログインに失敗しました。認証情報を確認してください。'); + } finally { + setIsLoading(false); + } + }; + + return ( + + e.stopPropagation()} + > +

atprotoログイン

+ +
+
+ + setIdentifier(e.target.value)} + placeholder="your.handle または did:plc:..." + required + disabled={isLoading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="アプリパスワード" + required + disabled={isLoading} + /> + + メインパスワードではなく、 + + アプリパスワード + + を使用してください + +
+ + {error && ( +
{error}
+ )} + +
+ + +
+
+ +
+

+ ai.cardはatprotoアカウントを使用します。 + データはあなたのPDSに保存されます。 +

+
+
+
+ ); +}; \ No newline at end of file diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..0f6f19e --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,9 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) \ No newline at end of file diff --git a/web/src/services/api.ts b/web/src/services/api.ts new file mode 100644 index 0000000..da733ab --- /dev/null +++ b/web/src/services/api.ts @@ -0,0 +1,31 @@ +import axios from 'axios'; +import { CardDrawResult } from '../types/card'; + +const API_BASE = '/api/v1'; + +const api = axios.create({ + baseURL: API_BASE, + headers: { + 'Content-Type': 'application/json', + }, +}); + +export const cardApi = { + drawCard: async (userDid: string, isPaid: boolean = false): Promise => { + const response = await api.post('/cards/draw', { + user_did: userDid, + is_paid: isPaid, + }); + return response.data; + }, + + getUserCards: async (userDid: string) => { + const response = await api.get(`/cards/user/${userDid}`); + return response.data; + }, + + getUniqueCards: async () => { + const response = await api.get('/cards/unique'); + return response.data; + }, +}; \ No newline at end of file diff --git a/web/src/services/auth.ts b/web/src/services/auth.ts new file mode 100644 index 0000000..584d022 --- /dev/null +++ b/web/src/services/auth.ts @@ -0,0 +1,107 @@ +import axios from 'axios'; + +const API_BASE = '/api/v1'; + +interface LoginRequest { + identifier: string; // Handle or DID + password: string; // App password +} + +interface LoginResponse { + access_token: string; + token_type: string; + did: string; + handle: string; +} + +interface User { + did: string; + handle: string; +} + +class AuthService { + private token: string | null = null; + private user: User | null = null; + + constructor() { + // Load token from localStorage + this.token = localStorage.getItem('ai_card_token'); + + // Set default auth header if token exists + if (this.token) { + axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`; + } + } + + async login(identifier: string, password: string): Promise { + try { + const response = await axios.post(`${API_BASE}/auth/login`, { + identifier, + password + }); + + const { access_token, did, handle } = response.data; + + // Store token + this.token = access_token; + localStorage.setItem('ai_card_token', access_token); + + // Set auth header + axios.defaults.headers.common['Authorization'] = `Bearer ${access_token}`; + + // Store user info + this.user = { did, handle }; + + return response.data; + } catch (error) { + throw new Error('Login failed'); + } + } + + async logout(): Promise { + try { + await axios.post(`${API_BASE}/auth/logout`); + } catch (error) { + // Ignore errors + } + + // Clear token + this.token = null; + this.user = null; + localStorage.removeItem('ai_card_token'); + delete axios.defaults.headers.common['Authorization']; + } + + async verify(): Promise { + if (!this.token) { + return null; + } + + try { + const response = await axios.get(`${API_BASE}/auth/verify`); + if (response.data.valid) { + this.user = { + did: response.data.did, + handle: response.data.handle + }; + return this.user; + } + } catch (error) { + // Token is invalid + this.logout(); + } + + return null; + } + + getUser(): User | null { + return this.user; + } + + isAuthenticated(): boolean { + return this.token !== null; + } +} + +export const authService = new AuthService(); +export type { User, LoginRequest, LoginResponse }; \ No newline at end of file diff --git a/web/src/styles/Card.css b/web/src/styles/Card.css new file mode 100644 index 0000000..2b30823 --- /dev/null +++ b/web/src/styles/Card.css @@ -0,0 +1,151 @@ +.card { + width: 250px; + height: 350px; + border-radius: 12px; + background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); + border: 2px solid #333; + overflow: hidden; + position: relative; + cursor: pointer; + transition: transform 0.3s ease; +} + +.card:hover { + transform: translateY(-5px); +} + +.card-inner { + padding: 20px; + height: 100%; + display: flex; + flex-direction: column; + position: relative; + z-index: 1; +} + +/* Rarity effects */ +.card-normal { + border-color: #666; +} + +.card-rare { + border-color: #4a90e2; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); +} + +.card-super-rare { + border-color: #9c27b0; + background: linear-gradient(135deg, #2d1b69 0%, #0f0c29 100%); +} + +.card-kira { + border-color: #ffd700; + background: linear-gradient(135deg, #232526 0%, #414345 100%); + position: relative; +} + +.card-kira::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient( + 45deg, + transparent 30%, + rgba(255, 215, 0, 0.1) 50%, + transparent 70% + ); + animation: shimmer 3s infinite; +} + +.card-unique { + border-color: #ff00ff; + background: linear-gradient(135deg, #000000 0%, #1a0033 100%); + box-shadow: 0 0 30px rgba(255, 0, 255, 0.5); +} + +.card-unique::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: radial-gradient( + circle at center, + transparent 0%, + rgba(255, 0, 255, 0.2) 100% + ); + animation: pulse 2s infinite; +} + +/* Card content */ +.card-header { + display: flex; + justify-content: space-between; + font-size: 14px; + color: #888; + margin-bottom: 20px; +} + +.card-content { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.card-name { + font-size: 28px; + margin: 0; + color: var(--card-color, #fff); + text-align: center; + font-weight: bold; +} + +.unique-badge { + margin-top: 10px; + padding: 5px 15px; + background: linear-gradient(90deg, #ff00ff, #00ffff); + border-radius: 20px; + font-size: 12px; + font-weight: bold; + animation: glow 2s ease-in-out infinite; +} + +.card-skill { + margin-top: 20px; + padding: 10px; + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + font-size: 12px; +} + +.card-footer { + text-align: center; + font-size: 12px; + color: #666; + text-transform: uppercase; + letter-spacing: 1px; +} + +/* Animations */ +@keyframes shimmer { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(100%); } +} + +@keyframes pulse { + 0% { opacity: 0.5; } + 50% { opacity: 1; } + 100% { opacity: 0.5; } +} + +@keyframes glow { + 0% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); } + 50% { box-shadow: 0 0 20px rgba(255, 0, 255, 0.8); } + 100% { box-shadow: 0 0 5px rgba(255, 0, 255, 0.5); } +} \ No newline at end of file diff --git a/web/src/styles/GachaAnimation.css b/web/src/styles/GachaAnimation.css new file mode 100644 index 0000000..23febb6 --- /dev/null +++ b/web/src/styles/GachaAnimation.css @@ -0,0 +1,120 @@ +.gacha-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.9); + z-index: 1000; +} + +.gacha-opening { + position: relative; +} + +.gacha-pack { + width: 200px; + height: 280px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 16px; + position: relative; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5); +} + +.pack-glow { + position: absolute; + top: -20px; + left: -20px; + right: -20px; + bottom: -20px; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + animation: glow-pulse 2s ease-in-out infinite; +} + +/* Effect variations */ +.effect-normal { + background: radial-gradient(circle, rgba(255, 255, 255, 0.1) 0%, transparent 50%); +} + +.effect-rare { + background: radial-gradient(circle, rgba(74, 144, 226, 0.2) 0%, transparent 50%); +} + +.effect-kira { + background: radial-gradient(circle, rgba(255, 215, 0, 0.3) 0%, transparent 50%); +} + +.effect-kira::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: url('data:image/svg+xml,'); + background-size: 50px 50px; + animation: sparkle 3s linear infinite; +} + +.effect-unique { + background: radial-gradient(circle, rgba(255, 0, 255, 0.4) 0%, transparent 50%); + overflow: hidden; +} + +.unique-effect { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; +} + +.unique-particles { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-image: + radial-gradient(circle, #ff00ff 1px, transparent 1px), + radial-gradient(circle, #00ffff 1px, transparent 1px); + background-size: 50px 50px, 30px 30px; + background-position: 0 0, 25px 25px; + animation: particle-float 20s linear infinite; +} + +.unique-burst { + position: absolute; + top: 50%; + left: 50%; + width: 300px; + height: 300px; + transform: translate(-50%, -50%); + background: radial-gradient(circle, rgba(255, 0, 255, 0.8) 0%, transparent 70%); + animation: burst 1s ease-out; +} + +/* Animations */ +@keyframes glow-pulse { + 0%, 100% { opacity: 0.5; transform: scale(1); } + 50% { opacity: 1; transform: scale(1.1); } +} + +@keyframes sparkle { + 0% { transform: translateY(0) rotate(0deg); } + 100% { transform: translateY(-100vh) rotate(360deg); } +} + +@keyframes particle-float { + 0% { transform: translate(0, 0); } + 100% { transform: translate(-50px, -100px); } +} + +@keyframes burst { + 0% { transform: translate(-50%, -50%) scale(0); opacity: 1; } + 100% { transform: translate(-50%, -50%) scale(3); opacity: 0; } +} \ No newline at end of file diff --git a/web/src/styles/Login.css b/web/src/styles/Login.css new file mode 100644 index 0000000..4dc82e6 --- /dev/null +++ b/web/src/styles/Login.css @@ -0,0 +1,152 @@ +.login-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(5px); +} + +.login-modal { + background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); + border: 1px solid #444; + border-radius: 16px; + padding: 40px; + max-width: 400px; + width: 90%; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.login-modal h2 { + margin: 0 0 30px 0; + font-size: 28px; + text-align: center; + background: linear-gradient(90deg, #fff700 0%, #ff00ff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.form-group { + margin-bottom: 20px; +} + +.form-group label { + display: block; + margin-bottom: 8px; + color: #ccc; + font-size: 14px; + font-weight: 500; +} + +.form-group input { + width: 100%; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid #444; + border-radius: 8px; + color: white; + font-size: 16px; + transition: all 0.3s ease; +} + +.form-group input:focus { + outline: none; + border-color: #fff700; + background: rgba(255, 255, 255, 0.15); + box-shadow: 0 0 0 2px rgba(255, 247, 0, 0.2); +} + +.form-group input:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.form-group small { + display: block; + margin-top: 6px; + color: #888; + font-size: 12px; +} + +.form-group small a { + color: #fff700; + text-decoration: none; +} + +.form-group small a:hover { + text-decoration: underline; +} + +.error-message { + background: rgba(255, 71, 87, 0.1); + border: 1px solid rgba(255, 71, 87, 0.3); + border-radius: 8px; + padding: 12px; + margin-bottom: 20px; + color: #ff4757; + font-size: 14px; +} + +.button-group { + display: flex; + gap: 12px; + margin-top: 30px; +} + +.login-button, +.cancel-button { + flex: 1; + padding: 14px 24px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: bold; + cursor: pointer; + transition: all 0.3s ease; +} + +.login-button { + background: linear-gradient(135deg, #fff700 0%, #ffd700 100%); + color: #000; +} + +.login-button:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(255, 247, 0, 0.4); +} + +.login-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.cancel-button { + background: rgba(255, 255, 255, 0.1); + color: white; + border: 1px solid #444; +} + +.cancel-button:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + border-color: #666; +} + +.login-info { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #333; + text-align: center; +} + +.login-info p { + color: #888; + font-size: 14px; + line-height: 1.6; + margin: 0; +} \ No newline at end of file diff --git a/web/src/types/card.ts b/web/src/types/card.ts new file mode 100644 index 0000000..e9d7a77 --- /dev/null +++ b/web/src/types/card.ts @@ -0,0 +1,24 @@ +export enum CardRarity { + NORMAL = "normal", + RARE = "rare", + SUPER_RARE = "super_rare", + KIRA = "kira", + UNIQUE = "unique" +} + +export interface Card { + id: number; + cp: number; + status: CardRarity; + skill?: string; + owner_did: string; + obtained_at: string; + is_unique: boolean; + unique_id?: string; +} + +export interface CardDrawResult { + card: Card; + is_new: boolean; + animation_type: string; +} \ No newline at end of file diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d0104ed --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"], + "references": [{ "path": "./tsconfig.node.json" }] +} \ No newline at end of file diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..4eb43d0 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "strict": true + }, + "include": ["vite.config.ts"] +} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..6dc68ec --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + server: { + port: 3000, + proxy: { + '/api': { + target: 'http://localhost:8000', + changeOrigin: true, + } + } + } +}) \ No newline at end of file