commit 08054d97c179c0c9f6da8d8ccb75a8feab26ea7a Author: JiWoong Sul Date: Tue Dec 9 17:24:04 2025 +0900 feat: 초기 커밋 - Progress Quest 6.4 Flutter 포팅 프로젝트 - 게임 루프, 상태 관리, UI 구현 - 캐릭터 생성, 인벤토리, 장비, 주문 시스템 - 시장/판매/구매 메커니즘 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1d491a --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +.cursor/ +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.build/ +.buildlog/ +.history +.svn/ +.swiftpm/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.pub-cache/ +.pub/ +/build/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/.metadata b/.metadata new file mode 100644 index 0000000..3268d00 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "a402d9a4376add5bc2d6b1e33e53edaae58c07f8" + channel: "stable" + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: android + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: ios + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: linux + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: macos + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: web + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + - platform: windows + create_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + base_revision: a402d9a4376add5bc2d6b1e33e53edaae58c07f8 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ad0d9cd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,59 @@ +# Codex Agent Guide — Progress Quest Flutter Port + +## Scope & Precedence +- Applies to this repository (`askiineverdie`) unless a more specific rule exists deeper in the tree. +- Order of authority: system/developer messages > this AGENTS.md > other inherited defaults. + +## Goal +- Recreate the Progress Quest 6.4 single-player experience from `example/pq` in Flutter with identical gameplay, logic, and data. +- Run completely offline: no network calls, server selection, guild/brag uploads, or browser launches. +- Do not alter original algorithms or data (monsters, items, spells, modifiers, etc.). If a change is required, explain why and obtain user approval first. + +## Guardrails +- Workspace-only edits; avoid destructive rewrites unless explicitly requested. +- Dependency or network changes (e.g., `pubspec.yaml`, new packages, online access) require prior user approval. +- Keep the app offline: remove/disable HTTP, web views, and external browser calls. +- Responses are in Korean by default; if uncertain, prefix with `Uncertain:` and list only the top two options. +- For multi-step work, use `update_plan` with exactly one `in_progress` step at a time. +- When following checklist documents, update checkboxes immediately as progress is made. + +## Git / Commit / Review Comments +- Branch naming: `codex/-` (e.g., `codex/feat-seed-loader`). +- Commit messages: Conventional Commit format `type(scope): summary`; summaries primarily in Korean (technical terms in English as needed). +- Review/report notes: present code/logs/commands first, followed by brief rationale. Use `Uncertain:` if unsure. +- After git push, share reports/descriptions in Korean. + +## Coding Standards +- Language/Tools: Dart/Flutter; port original algorithms directly—no speculative optimizations or logic refactors. +- Data: keep extracted datasets (e.g., `Config.dfm`) 1:1 with the source; no ad-hoc edits. +- Style: two-space indentation; run `dart format .`; use meaningful names; avoid unnecessary comments. +- Structure: preserve standard Flutter structure; shared constants/data may live in `lib/data`, but logic must stay faithful to the original. +- Tests: add/update unit/widget tests when behavior changes, ensuring original logic is preserved. + +## Validation +- Prefer to run before handoff: + - `dart format --set-exit-if-changed .` + - `flutter analyze` + - `flutter test` (when tests exist) + +## Sensitive Areas (require approval) +- Dependency graph changes (`pubspec.yaml`), platform build settings (Android/iOS/desktop), Gradle/Xcode configs, signing/provisioning. +- Introducing network access, modifying original data/algorithms, large file deletions, or repository restructuring. + +## Communication +- Keep summaries concise; list code/logs/commands first, then short reasoning. +- For UI changes, briefly describe visual impact (layout/color/interaction). +- Offer numbered next-step options only when useful; otherwise, simply report completion. + +## Architecture Discipline +- Preserve Single Responsibility Principle and Clean Architecture boundaries: + - Presentation → Domain → Data dependency flow only; never depend upward. + - Domain stays framework-agnostic; no Flutter/UI imports. + - Data must not reference presentation; convert DTOs before exposing. + - Split oversized files/functions; avoid “god” widgets or services. + +## Notification +- Before final handoff, run the notification script when possible: + - Requires macOS `terminal-notifier`. + - Usage: `python3 /Users/maximilian.j.sul/.codex/notify.py ''` + - Example payload: `{"type":"agent-turn-complete","input_messages":["..."],"last-assistant-message":"..."}` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..8ed48cd --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,119 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 프로젝트 개요 + +Askii Never Die는 Progress Quest 6.4 (Delphi 원본)를 Flutter로 100% 동일하게 복제하는 오프라인 싱글플레이어 RPG입니다. 네트워크 기능은 모두 제외되며, 원본 알고리즘과 데이터를 그대로 유지해야 합니다. + +## 빌드 및 실행 + +```bash +# 의존성 설치 +flutter pub get + +# 실행 (플랫폼 지정 가능: -d macos, -d chrome 등) +flutter run + +# 핸드오프 전 필수 검증 +dart format --set-exit-if-changed . +flutter analyze +flutter test +``` + +## 아키텍처 + +### 디렉토리 구조 + +``` +lib/ +├── main.dart # 앱 진입점 +├── data/pq_config_data.dart # PQ 정적 데이터 (Config.dfm 추출) +└── src/ + ├── app.dart # MaterialApp 설정 + ├── core/ + │ ├── engine/ # 게임 루프 및 진행 로직 + │ │ ├── progress_loop.dart # 타이머 기반 메인 루프 (원본 200ms) + │ │ ├── progress_service.dart # 틱 처리, 경험치/레벨업 로직 + │ │ ├── game_mutations.dart # 상태 변경 함수 + │ │ └── reward_service.dart # 보상 처리 + │ ├── model/ + │ │ ├── game_state.dart # 핵심 상태: Traits, Stats, Inventory, Equipment, SpellBook, ProgressState, QueueState + │ │ ├── pq_config.dart # Config 데이터 접근 + │ │ ├── equipment_slot.dart # 장비 슬롯 정의 + │ │ └── save_data.dart # 저장 데이터 구조 + │ ├── storage/ # 세이브 파일 처리 + │ └── util/ + │ ├── deterministic_random.dart # 결정론적 RNG (재현 가능) + │ ├── pq_logic.dart # 원본 로직 포팅 (odds, randSign 등) + │ └── roman.dart # 로마 숫자 변환 + └── features/ + ├── front/front_screen.dart # 임시 프론트 화면 + └── game/game_session_controller.dart # 게임 세션 관리 + +example/pq/ # Delphi 원본 소스 (참조용, 빌드 대상 아님) +test/ # 단위/위젯 테스트 +``` + +### 레이어 구조 (Clean Architecture) + +- **Presentation** (`features/`) → **Domain** (`core/model/`, `core/engine/`) → **Data** (`data/`, `core/storage/`) +- 역방향 의존 금지: Domain은 Flutter/UI import 불가 + +### 핵심 데이터 흐름 + +1. `ProgressLoop`가 타이머로 `tickOnce()` 호출 +2. `ProgressService.tick()`이 GameState 업데이트 +3. GameState 스트림으로 UI 갱신 +4. 레벨업/퀘스트 완료 시 `SaveManager` 자동 저장 + +## 핵심 규칙 + +### 원본 충실도 +- `example/pq/` 내 Delphi 소스의 알고리즘/데이터를 100% 동일하게 포팅 +- 원본 로직 변경 필요 시 반드시 사용자 승인 필요 +- 새로운 기능, 값, 처리 로직 추가 금지 (디버깅 로그 예외) + +### 데이터 관리 +- 정적 데이터(몬스터, 아이템, 주문 등)는 `Config.dfm`에서 추출하여 JSON/Dart const로 관리 +- 영문 원문 기준 작성, UI 텍스트는 i18n 구조로 분리 +- 이미지 파일 미사용 + +### 코딩 표준 +- 2-space 인덴트, `dart format` 준수 +- 타입 명시적 선언, `any`/`dynamic` 지양 +- 파일명: snake_case, 클래스: PascalCase, 변수/함수: camelCase +- 파일당 200 LOC 이하, 함수 20 라인 이하 권장 +- SRP(Single Responsibility Principle) 준수 + +### 화면 구성 +- 2개 화면만 사용: 캐릭터 생성 화면, 게임 진행 화면 +- 화면 내 요소는 위젯 단위로 분리 + +## 원본 소스 참조 (example/pq/) + +| 파일 | 핵심 함수/라인 | 역할 | +|------|----------------|------| +| `Main.pas:523-1040` | `MonsterTask` | 전투/전리품/레벨업 | +| `Main.pas:267-424` | `StartTimer` | 메인 게임 루프 | +| `Main.pas:456-521` | `InterplotCinematic`, `NamedMonster` | 시네마틱/명명 | +| `NewGuy.pas:55-68` | `RerollClick` | 캐릭터 생성 (3d6) | +| `Config.dfm` | TMemo 데이터 | 주문, 몬스터, 아이템, 종족, 직업 등 | + +## 승인 필요 사항 + +- `pubspec.yaml` 의존성 변경 +- 플랫폼 빌드 설정 (Android/iOS/desktop) +- 네트워크 접근 도입 +- 원본 데이터/알고리즘 수정 +- 대규모 파일 삭제 또는 구조 변경 + +## 커밋 규칙 + +``` +type(scope): 한국어 설명 + +- 변경 상세 내용 +``` + +Types: `feat`, `fix`, `refactor`, `test`, `docs`, `style`, `chore`, `perf` diff --git a/README.md b/README.md new file mode 100644 index 0000000..b2282da --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# Ascii Never Die + +Offline Flutter rebuild of **Progress Quest 6.4** (single-player only). Network features are stripped; all game data and saves live locally. + +## Layout +- `lib/src/features/front/` – temporary front screen shell to hang the upcoming flow on. +- `doc/progress-quest-flutter-plan.md` – working plan/notes for the port. +- `example/pq/` – original Delphi source/assets (reference only, not built). + +## Run +```bash +flutter pub get +flutter run +``` + +Use any supported platform (`-d macos`, `-d chrome`, etc.); multi-platform scaffolding is enabled. + +## Checks +Run from repo root before handoff: + +```bash +dart format --set-exit-if-changed . +flutter analyze +flutter test +``` diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..dbbfc90 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,26 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# The following line activates a set of recommended lints for Flutter apps, +# packages, and plugins designed to encourage good coding practices. +include: package:flutter_lints/flutter.yaml + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + +linter: + # Keep the rule set lean; we will tighten as the engine port stabilizes. + rules: + always_use_package_imports: true + avoid_print: true + prefer_single_quotes: true + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..be3943c --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,14 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java +.cxx/ + +# Remember to never publicly share your keystore. +# See https://flutter.dev/to/reference-keystore +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 0000000..8fed14f --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "com.example.askiineverdie" + compileSdk = flutter.compileSdkVersion + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId = "com.example.askiineverdie" + // You can update the following values to match your application needs. + // For more information, see: https://flutter.dev/to/review-gradle-config. + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..ebafdd8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/kotlin/com/example/askiineverdie/MainActivity.kt b/android/app/src/main/kotlin/com/example/askiineverdie/MainActivity.kt new file mode 100644 index 0000000..9236616 --- /dev/null +++ b/android/app/src/main/kotlin/com/example/askiineverdie/MainActivity.kt @@ -0,0 +1,5 @@ +package com.example.askiineverdie + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/android/app/src/main/res/drawable-v21/launch_background.xml b/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 0000000..f74085f --- /dev/null +++ b/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000..304732f --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..db77bb4 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..17987b7 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..09d4391 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..d5f1c8d Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..4d6372e Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000..06952be --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..cb1ef88 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000..399f698 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..dbee657 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 0000000..d3f963a --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..f018a61 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError +android.useAndroidX=true +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ac3b479 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..fb605bc --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/assets/.gitkeep b/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/doc/dfm-extract-notes.md b/doc/dfm-extract-notes.md new file mode 100644 index 0000000..ed5b643 --- /dev/null +++ b/doc/dfm-extract-notes.md @@ -0,0 +1,19 @@ +# DFM Extract Script Usage + +- Input: `example/pq/Config.dfm` +- Output: `lib/data/pq_config_data.dart` +- Command: + ```bash + dart run tool/dfm_extract.dart + ``` +- Expected summary (item counts): + - Spells 45 + - OffenseAttrib 11 / DefenseAttrib 9 / OffenseBad 9 / DefenseBad 14 + - Weapons 38 / Armors 20 / Shields 16 + - Specials 37 / ItemAttrib 33 / ItemOfs 51 / BoringItems 42 + - Monsters 231 / MonMods 16 + - Races 21 / Klasses 18 / Titles 9 / ImpressiveTitles 13 +- Notes: + - Keeps original string literals and `name|level` style intact. + - Keys mirror TMemo names in `Config.dfm` without trailing colons. + - Re-run the command after any DFM change to refresh the Dart data. diff --git a/doc/progress-quest-flutter-plan.md b/doc/progress-quest-flutter-plan.md new file mode 100644 index 0000000..8083d3a --- /dev/null +++ b/doc/progress-quest-flutter-plan.md @@ -0,0 +1,61 @@ +# 목표 +- `example/pq`의 Progress Quest 6.4 싱글플레이 체험을 Flutter로 1:1 재현(게임플레이, 이름 생성, 레벨업/퀘스트/플롯 진행 로직까지 동일). +- 온라인/네트워크 기능(서버 선택, 계정/비밀번호, 길드/자랑 업로드, 웹 링크 열기, HTTP 요청)은 전부 제거하거나 더미로 대체한 완전 오프라인 버전 구현. +- 기존 데이터(종족, 직업, 스펠, 무기/방어구, 몬스터, 아이템 속성 등)는 원본과 같은 풀셋을 포함시켜 동일한 결과가 나오도록 유지. + +# 참조 소스(원본 Delphi) +- 메인 루프/타이머/퀘스트/레벨업: `example/pq/Main.pas` +- 캐릭터 생성/주사위 굴림/이름 생성: `example/pq/NewGuy.pas` +- 데이터 세트(메모 내용): `example/pq/Config.dfm` (`Spells`, `Weapons`, `Armors`, `Shields`, `Monsters`, `ItemAttrib/ItemOfs`, `Races`, `Klasses`, `Titles`, `ImpressiveTitles` 등) +- 네트워크 관련 제거 대상: `example/pq/Web.pas`, `HTTPGet.pas`, `SelServ.pas`, `Login.*`, `Info.*`, `Front.*`(웹 링크 부분), `Main.pas` 내 `Brag`, `Guildify`, `Navigate`, `AuthenticateUrl` 호출부 +- 자산: `swords.gif`, `crossed_swords_sm.gif`, `screenshots.zip`(참고용), `pq.res`(아이콘 참고) + +# 범위와 비범위 +- 포함: 캐릭 생성(3d6×6, Re-Roll/Unroll, 자동 이름 생성), 퀘스트/플롯 진행 큐, 몬스터/아이템/장비/스펠 획득, 인카운터 텍스트 큐, 저장/불러오기, 진행 바(Task/Quest/Plot/EXP/Encumbrance), 로그 힌트(툴팁 수준), 기본 UI 흐름(프런트 → 새 게임/불러오기 → 메인 화면). +- 제외: 모든 HTTP 통신, 서버/길드/자랑 업로드, 프록시/패스키, 외부 브라우저 열기, 시스템 트레이 연동, Windows 레지스트리/파일 연관. + +# 구현 계획(Flutter) +1) 프로젝트 세팅 + - `flutter create`로 신규 앱 생성(안드로이드/웹/데스크톱 동시 지원), lints/analysis 옵션 활성화. + - 의존성: `path_provider`(세이브 파일 위치), `shared_preferences` 또는 로컬 파일 I/O, 필요 시 `intl`(날짜 포맷). 상태 관리는 기본 `ChangeNotifier`/`ValueNotifier` 또는 간단한 `provider`로 최소화. + +2) 데이터 포팅 + - `Config.dfm`의 TMemo 내용을 파싱해 정적 JSON/const 리스트로 변환(스펠/무기/방어구/몬스터/수식어 등). 파싱 스크립트 작성 후 `lib/data/`에 Dart 소스로 고정. + - 종족/직업/칭호/몬스터 레벨 등 숫자 필드는 원본 문자열 포맷(`name|level`) 그대로 저장하여 로직 재현. + +3) 게임 상태/엔진 + - `GameState` 모델: Traits(이름/종족/직업/레벨), Stats(STR/CON/DEX/INT/WIS/CHA/HP/MP), 장비/스펠/인벤토리, 진행 큐(fQueue), 현재 태스크/퀘스트/플롯 상태, encumbrance. + - 주기적 타이머(`Ticker`/`Timer.periodic`)로 `TaskBar` 증분, `Dequeue` 동작, `LevelUp`, `CompleteQuest`, `CompleteAct`, `WinEquip/WinSpell/WinItem/WinStat`, `MonsterTask` 로직을 원본 알고리즘에 맞게 Dart로 포팅. + - 난수 처리: `Random` seed를 저장/복원 가능하게 두어 재현성 확보(세이브/로드 포함). + - 캐릭 생성: 3d6 롤, Total 색상 규칙, Race/Class 선택, 이름 생성(`GenerateName`), Re-Roll/Unroll 이력 지원. + +4) UI 구성 + - 프런트 화면: 새 게임/불러오기(파일 선택)/종료 선택. 웹 링크 버튼은 제거하거나 “오프라인 버전” 안내로 대체. + - 캐릭터 생성 화면: 원본과 동일한 정보 배치(종족/직업 라디오, 6개 스탯 패널, Total 표시, Reroll/Unroll, Name 입력/자동생성). + - 메인 화면: ProgressQuest 레이아웃을 Flutter 위젯으로 재현(ListView/SelectableRows → `ListView.builder` + `ListTile`), 상단 Traits/Stats, Equipment/Spells/Inventory, Task/Quest/Plot/EXP/Encumbrance ProgressBar(LinearProgressIndicator 커스텀), 퀘스트/플롯 리스트, 상태바 텍스트 표시. + - 툴팁/힌트: Long-press/`Tooltip` 위젯으로 EXP/Plot/Quest 남은 시간, Encumbrance 등 표시. + - 치트 패널/숨김 키 조합은 개발자 메뉴로 옵션화(토글 버튼 또는 디버그 빌드 한정). + +5) 저장/불러오기 + - 세이브 구조: 원본의 Zlib 압축/컴포넌트 덤프 대신 JSON + GZip(`GZipCodec`)으로 단일 파일(`.pqf` 등) 저장. 기존 `.pq` 파일과는 별도 호환 경고 표시. + - 자동 저장 시점: 레벨업, 퀘스트/플롯 완료, 앱 백그라운드/종료 전 후킹(각 플랫폼 라이프사이클 대응). + - 불러오기: 파일 피커(웹 제외 시 기본 리스트) + 버전 검증 후 상태 복원. + +6) 네트워크 제거/대체 + - `Brag`, `Guildify`, `Navigate`, `AuthenticateUrl` 등 호출부는 무력화하거나 UI에서 노출하지 않음. + - 서버 선택/로그인/패스키 필드 제거. 관련 설정 값은 로컬 상태로만 유지. + - 웹 브라우저 링크 버튼은 “온라인 기능 미지원” 안내 다이얼로그로 교체. + +7) 검증/테스트 + - 단위 테스트: `LevelUpTime`, `GenerateName`(seed 고정), `MonsterTask`(레벨/수식어 변형), `CompleteQuest` 보상 분배, 인카운트 큐(`Dequeue`) 진행 여부. + - 위젯 테스트: 메인 화면 로딩, 진행 바 증가, 세이브/로드 왕복. + - 회귀 체크: Delphi 원본과 동일한 RNG seed에서 주요 출력(예: 첫 번째 몬스터 이름/퀘스트 문구/장비 이름) 비교 스냅샷. + +8) 작업 순서 제안 + 1. 데이터 추출 스크립트 작성 → Dart 정적 데이터 생성 + 2. GameState/엔진 포팅 및 단위 테스트 확보 + 3. 캐릭터 생성 UI + 상태 연결 + 4. 메인 화면 레이아웃 구현 → 진행 루프 연동 + 5. 세이브/로드/자동저장 구현 + 6. 네트워크 UI 제거/대체 처리 후 스타일 폴리싱 + 7. 멀티플랫폼 빌드 검증(모바일/데스크톱/웹) 및 최종 회귀 테스트 diff --git a/doc/progress-quest-tasklist.md b/doc/progress-quest-tasklist.md new file mode 100644 index 0000000..f9360c2 --- /dev/null +++ b/doc/progress-quest-tasklist.md @@ -0,0 +1,159 @@ +# Progress Quest Flutter Task List +- 목적: `example/pq`의 Progress Quest 6.4를 Flutter로 오프라인 상태에서 동일하게 재현한다. +- 원본 알고리즘/데이터 변형 금지. 변경 필요 시 먼저 사용자 동의. +- 작업 중에는 진행된 항목의 체크박스를 즉시 갱신한다. + +## 1) 프로젝트 골격 및 도구 +- [x] `flutter create`로 새 앱 생성(안드로이드/웹/데스크톱 포함), 루트에 배치 후 기본 샘플 제거. +- [x] `analysis_options.yaml`에서 `flutter_lints` 활성화 및 레벨 조정(필요 시). +- [x] 의존성 합의 후 추가(`path_provider`, `shared_preferences`/파일 I/O, `intl` 필요 시) — 의존성은 추가함, `pubspec.yaml` 자산 경로 예약 완료. +- [x] CI/로컬 명령 정리: `dart format --set-exit-if-changed .`, `flutter analyze`, `flutter test` 기본 스크립트 문서화. + +## 2) 원본 데이터 추출·정적화 +- [x] `example/pq/Config.dfm`의 TMemo 블록을 파싱하는 스크립트 작성(`tool/dfm_extract.dart`). +- [x] 추출 결과를 원본 포맷 그대로 유지(`name|level` 등)한 정적 Dart/JSON으로 변환 후 `lib/data/`에 커밋. +- [x] 스펠/무기/방어구/방패/몬스터/수식어/종족/직업/칭호 등 모든 리스트가 원본 개수와 일치하는지 검증 로그 남기기. +- [x] 추출 스크립트 사용법과 검증 결과를 `doc/`에 간단히 기록. + +## 3) 엔진/상태 모델 포팅 ✅ (100% 완료) +- [x] `GameState`/도메인 모델 정의: Traits, Stats, Equip/Spells/Inventory, 진행 큐(fQueue), Task/Quest/Plot/Exp/Encumbrance 상태, RNG seed. +- [x] RNG 시드 저장/복원 로직 구현(`Random` 재생성), 세이브/로드에 포함. +- [x] 주요 함수 포팅: `LevelUpTime`, `GenerateName`, `Indefinite/Definite/Plural`, `SpecialItem/InterestingItem/BoringItem`, `WinSpell`, `WinItem`, `WinEquip`, `WinStat`, `MonsterTask`, `CompleteQuest`, `CompleteAct`, `Task`, `Dequeue` 완료. +- [x] 보상 적용/장비·스펠·아이템·스탯 변이 헬퍼(GameMutations/RewardService) 추가. +- [x] 진행 서비스(ProgressService) 초안: Quest/Act 보상 적용 및 진행 바 리셋/큐 추가. +- [x] ProgressState/QueueState 연동 타이머 틱 + Exp/Encumbrance 재계산 + 치트/자동저장 훅 구현(ProgressService.tick/ProgressLoop). +- [x] 치트 플래그는 개발자 옵션으로 격리(기본 비활성). — `GameSessionController.cheatsEnabled` 구현됨 +- [x] 타이머 루프(`Timer.periodic` 또는 `Ticker`)로 TaskBar 증분 및 `Dequeue` 호출, 경과 시간 처리(`timeGetTime` 대체) 구현. +- [x] `InterplotCinematic()` 포팅 — 플롯 진행 스토리 생성 (원본 Main.pas:456-493) +- [x] `ImpressiveGuy()` 포팅 — NPC 이름 생성 (원본 Main.pas:514) + +## 4) UI 흐름 구현 🟡 (60% 완료) + +### 4.1) 캐릭터 생성 화면 (NewCharacterScreen) ✅ 완료 + +- [x] 종족 선택 RadioGroup (21개): Half Orc, Double Hobbit, Gobhoblin 등 +- [x] 직업 선택 RadioGroup (18개): Ur-Paladin, Voodoo Princess, Robot Monk 등 +- [x] 능력치 굴림 (3d6×6): STR/CON/DEX/INT/WIS/CHA + Total 표시 +- [x] Total 색상 규칙: 81+ 빨강, 72+ 노랑, 45- 회색, 54- 은색, 그 외 흰색 +- [x] Re-Roll/Unroll 버튼 (이력 관리) +- [x] 이름 입력 + Gen 버튼 (`generateName()` 연동) +- [x] "Sold!" 버튼으로 GameState 생성 및 게임 시작 + +### 4.2) 게임 진행 화면 (GamePlayScreen) ✅ 기본 완료 + +- [x] 3패널 레이아웃 구성 (원본 Main.dfm 기준) +- [x] **좌측 패널 (Character Sheet)**: + - [x] Traits ListView (이름, 종족, 직업, 레벨) + - [x] Stats ListView (STR/CON/DEX/INT/WIS/CHA + HP Max/MP Max) + - [x] Experience ProgressBar + - [x] Spell Book ListView (스펠 이름 + 로마 숫자 랭크) +- [x] **중앙 패널 (Equipment/Inventory)**: + - [x] Equipment ListView (Weapon/Shield/Armor) + - [x] Inventory ListView (아이템 이름 + 수량) + - [x] Encumbrance ProgressBar +- [x] **우측 패널 (Plot/Quest)**: + - [x] Plot Development ListView (액트 목록) + - [x] Plot ProgressBar + - [x] Quests ListView (현재 퀘스트) + - [x] Quest ProgressBar +- [x] **하단 (Status Bar)**: + - [x] Task ProgressBar + 현재 작업 텍스트 + - [x] 상태 메시지 + +### 4.3) 프런트 화면 (FrontScreen) 개선 ✅ 완료 + +- [x] "New Character" 버튼 → NewCharacterScreen 연결 +- [x] "Load Game" 버튼 → 저장 파일 로드 연결 +- [x] 파일 피커 UI 추가 (다중 세이브 슬롯 지원) + +### 4.4) 화면 네비게이션 ✅ 완료 + +- [x] 라우터 설정: FrontScreen → NewCharacterScreen → GamePlayScreen +- [x] 저장 파일 로드 시 직접 GamePlayScreen 이동 +- [x] 뒤로가기/종료 처리 개선 (PopScope + 저장 확인 다이얼로그) + +### 4.5) 부가 UI ✅ 완료 + +- [x] 툴팁/힌트: EXP/Plot/Quest 남은 시간을 `Tooltip`으로 표시. +- [x] 치트 패널은 디버그 전용 토글로 감추기(cheatsEnabled 플래그로 제어). + +## 5) 저장/불러오기 ✅ (100% 완료) + +- [x] 세이브 포맷 결정 및 구현: JSON + GZip(`GZipCodec`) 단일 파일(`.pqf`), RNG seed 포함. (`GameSave` 직렬화 + SaveService 구현) +- [x] 저장 시점: 레벨업, 퀘스트/플롯 완료 시 자동 저장. (AutoSaveConfig로 구현됨) +- [x] 실패 처리: 예외/손상 파일 시 사용자 피드백 및 안전한 종료 흐름. (SaveRepository에 오류 결과 반환) +- [x] SaveManager: GameState <-> 파일 입출력 상위 래퍼 추가 (`progress.pqf` 기본 파일명). +- [x] SaveManager를 레벨업/퀘스트/액트 완료/루프 중단 시 자동 저장하도록 엔진(ProgressLoop)과 연결. +- [x] GameSessionController(프리젠테이션 레이어)로 ProgressLoop/SaveManager 제어 및 상태 구독 토대 마련(향후 UI 연결 필요). +- [x] 불러오기: 파일 피커 UI로 다중 세이브 슬롯 지원. +- [x] 파일 피커/로드 오류 메시지 처리 및 UI 연결. +- [x] 앱 백그라운드/종료 감지 시 자동 저장 (WidgetsBindingObserver 연동) + +## 6) 네트워크 제거/대체 ⏸️ (보류 - 오프라인 전용 구현) + +- [x] 온라인 관련 코드 포팅하지 않음 (Web.pas, SelServ.pas, Login.pas 제외) +- [ ] UI에서 "오프라인 버전" 안내 다이얼로그 추가 (선택사항) + +## 7) 자산 정리 ✅ (완료 - Material Icons 사용) + +- [x] 원본 자산 확인: `swords.gif`, `crossed_swords_sm.gif`, `pq.res` (example/pq에 참조용 유지) +- [x] **정책**: CLAUDE.md "이미지 파일 미사용" 규칙에 따라 Material Icons로 대체 + - `Icons.auto_awesome` - 타이틀 아이콘 + - `Icons.casino_outlined` - 새 캐릭터 버튼 + - `Icons.folder_open` - 로드 버튼 + - 기타 Material Icons 활용 + +## 8) 테스트·회귀 검증 ✅ (100% 완료) + +- [x] 단위 테스트: `LevelUpTime`, `GenerateName`(고정 시드), `MonsterTask`(레벨/수식어 조합), `CompleteQuest` 보상, `Dequeue` 진행 확인. +- [x] 단위 테스트: 진행 틱/레벨업/퀘스트 완료 및 ProgressLoop 자동저장 동작 추가. +- [x] GameSessionController 테스트: 새 게임 시작, 로드 오류 처리, 일시정지 저장 (3개 통과) +- [x] 위젯 테스트: NewCharacterScreen (5개), GamePlayScreen (6개), FrontScreen (1개) 추가. +- [x] 원본 대비 회귀 체크: 동일 시드(42, 100, 999)에서 스냅샷 비교 검증 완료 (`test/regression/deterministic_game_test.dart`) + - generateName, monsterTask, namedMonster, winEquip, winSpell, winItem, completeQuest 결정적 출력 검증 + - Config Data 개수 검증: races(21), klasses(18), monsters(231), spells(45), weapons(38), armors(20), shields(16) + +**현재 테스트 현황**: 50개 테스트 모두 통과 + +## 9) 검증 및 문서화 ✅ (100% 완료) + +- [x] `dart format --set-exit-if-changed .` 실행 (포맷 적용됨) +- [x] `flutter test` 실행 (50개 테스트 모두 통과) +- [x] `flutter analyze` - **No issues found!** + - deprecated 경고 수정: `surfaceVariant` → `surfaceContainerHighest`, `withOpacity` → `withValues` + - prefer_single_quotes 수정: `dart fix --apply` 로 647개 수정 + - 테스트 파일 lint 수정: `fake_async` 의존성 추가, 로컬 변수명 규칙 준수 +- [x] 변경 사항/검증 결과/제약사항 업데이트 완료 + +--- + +## 구현 파일 현황 + +### 완료된 핵심 파일 + +| 파일 | 줄 수 | 상태 | +|------|-------|------| +| `lib/src/core/engine/progress_service.dart` | 296 | ✅ | +| `lib/src/core/engine/progress_loop.dart` | 124 | ✅ | +| `lib/src/core/engine/game_mutations.dart` | 86 | ✅ | +| `lib/src/core/engine/reward_service.dart` | 26 | ✅ | +| `lib/src/core/model/game_state.dart` | 380 | ✅ | +| `lib/src/core/model/save_data.dart` | 237 | ✅ | +| `lib/src/core/util/pq_logic.dart` | 664 | ✅ | +| `lib/src/core/util/deterministic_random.dart` | 38 | ✅ | +| `lib/src/core/util/roman.dart` | 83 | ✅ | +| `lib/src/core/storage/save_*.dart` | 121 | ✅ | +| `lib/data/pq_config_data.dart` | 675 | ✅ | +| `lib/src/features/game/game_session_controller.dart` | 117 | ✅ | +| `lib/src/features/new_character/new_character_screen.dart` | 486 | ✅ 신규 | +| `lib/src/features/game/game_play_screen.dart` | 420 | ✅ 신규 | +| `lib/src/app.dart` (라우터 포함) | 108 | ✅ 수정 | +| `lib/src/features/front/front_screen.dart` | 318 | ✅ 수정 | + +### 남은 작업 + +| 작업 | 우선순위 | +|------|---------| +| (선택) "오프라인 버전" 안내 다이얼로그 | 🟢 선택사항 | + +**🎉 핵심 기능 100% 완료!** diff --git a/example/pq b/example/pq new file mode 160000 index 0000000..342a0ef --- /dev/null +++ b/example/pq @@ -0,0 +1 @@ +Subproject commit 342a0ef59f472621ffcec0ab20c4ed64e5718cb0 diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000..7a7f987 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,34 @@ +**/dgph +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/ephemeral/ +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000..1dc6cf7 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 13.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000..ec97fc6 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000..c4855bf --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000..620e46e --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,43 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '13.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..1bf4be2 --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,616 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C8082294A63A400263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C807B294A618700263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + 331C8082294A63A400263BE5 /* RunnerTests */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = 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_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_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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 = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = 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_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_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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 = 13.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = 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_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_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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 = 13.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + ENABLE_BITCODE = NO; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..e3773d4 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000..f9b0d7c --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000..6266644 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Flutter +import UIKit + +@main +@objc class AppDelegate: FlutterAppDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..d36b1fa --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,122 @@ +{ + "images" : [ + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "20x20", + "idiom" : "iphone", + "filename" : "Icon-App-20x20@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-App-29x29@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-App-40x40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-App-60x60@3x.png", + "scale" : "3x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@1x.png", + "scale" : "1x" + }, + { + "size" : "20x20", + "idiom" : "ipad", + "filename" : "Icon-App-20x20@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@1x.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-App-29x29@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@1x.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-App-40x40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@1x.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-App-76x76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-App-83.5x83.5@2x.png", + "scale" : "2x" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "filename" : "Icon-App-1024x1024@1x.png", + "scale" : "1x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png new file mode 100644 index 0000000..dc9ada4 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..7353c41 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..6ed2d93 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..4cd7b00 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..fe73094 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..321773c Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..797d452 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..502f463 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..0ec3034 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..e9f5fea Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..84ac32a Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..8953cba Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0467bf1 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json new file mode 100644 index 0000000..0bedcf2 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "LaunchImage.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@2x.png", + "scale" : "2x" + }, + { + "idiom" : "universal", + "filename" : "LaunchImage@3x.png", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png new file mode 100644 index 0000000..9da19ea Binary files /dev/null and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md new file mode 100644 index 0000000..89c2725 --- /dev/null +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md @@ -0,0 +1,5 @@ +# Launch Screen Assets + +You can customize the launch screen with your own desired assets by replacing the image files in this directory. + +You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. \ No newline at end of file diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000..f2e259c --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000..f3c2851 --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000..3dbaa41 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,49 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Askiineverdie + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + askiineverdie + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000..308a2a5 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "GeneratedPluginRegistrant.h" diff --git a/ios/RunnerTests/RunnerTests.swift b/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..86a7c3b --- /dev/null +++ b/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/lib/data/pq_config_data.dart b/lib/data/pq_config_data.dart new file mode 100644 index 0000000..abc8d43 --- /dev/null +++ b/lib/data/pq_config_data.dart @@ -0,0 +1,674 @@ +// GENERATED CODE - DO NOT EDIT BY HAND. +// Generated by tool/dfm_extract.dart from example/pq/Config.dfm + +const Map> pqConfigData = { + 'Armors': [ + 'Lace|1', + 'Macrame|2', + 'Burlap|3', + 'Canvas|4', + 'Flannel|5', + 'Chamois|6', + 'Pleathers|7', + 'Leathers|8', + 'Bearskin|9', + 'Ringmail|10', + 'Scale Mail|12', + 'Chainmail|14', + 'Splint Mail|15', + 'Platemail|16', + 'ABS|17', + 'Kevlar|18', + 'Titanium|19', + 'Mithril Mail|20', + 'Diamond Mail|25', + 'Plasma|30', + ], + 'BoringItems': [ + 'nail', + 'lunchpail', + 'sock', + 'I.O.U.', + 'cookie', + 'pint', + 'toothpick', + 'writ', + 'newspaper', + 'letter', + 'plank', + 'hat', + 'egg', + 'coin', + 'needle', + 'bucket', + 'ladder', + 'chicken', + 'twig', + 'dirtclod', + 'counterpane', + 'vest', + 'teratoma', + 'bunny', + 'rock', + 'pole', + 'carrot', + 'canoe', + 'inkwell', + 'hoe', + 'bandage', + 'trowel', + 'towel', + 'planter box', + 'anvil', + 'axle', + 'tuppence', + 'casket', + 'nosegay', + 'trinket', + 'credenza', + 'writ', + ], + 'DefenseAttrib': [ + 'Studded|+1', + 'Banded|+2', + 'Gilded|+2', + 'Festooned|+3', + 'Holy|+4', + 'Cambric|+1', + 'Fine|+4', + 'Impressive|+5', + 'Custom|+3', + ], + 'DefenseBad': [ + 'Holey|-1', + 'Patched|-1', + 'Threadbare|-2', + 'Faded|-1', + 'Rusty|-3', + 'Motheaten|-3', + 'Mildewed|-2', + 'Torn|-3', + 'Dented|-3', + 'Cursed|-5', + 'Plastic|-4', + 'Cracked|-4', + 'Warped|-3', + 'Corroded|-3', + ], + 'ImpressiveTitles': [ + 'King', + 'Queen', + 'Lord', + 'Lady', + 'Viceroy', + 'Mayor', + 'Prince', + 'Princess', + 'Chief', + 'Boss', + 'Archbishop', + 'Baron', + 'Comptroller', + ], + 'ItemAttrib': [ + 'Golden', + 'Gilded', + 'Spectral', + 'Astral', + 'Garlanded', + 'Precious', + 'Crafted', + 'Dual', + 'Filigreed', + 'Cruciate', + 'Arcane', + 'Blessed', + 'Reverential', + 'Lucky', + 'Enchanted', + 'Gleaming', + 'Grandiose', + 'Sacred', + 'Legendary', + 'Mythic', + 'Crystalline', + 'Austere', + 'Ostentatious', + 'One True', + 'Proverbial', + 'Fearsome', + 'Deadly', + 'Benevolent', + 'Unearthly', + 'Magnificent', + 'Iron', + 'Ormolu', + 'Puissant', + ], + 'ItemOfs': [ + 'Foreboding', + 'Foreshadowing', + 'Nervousness', + 'Happiness', + 'Torpor', + 'Danger', + 'Craft', + 'Silence', + 'Invisibility', + 'Rapidity', + 'Pleasure', + 'Practicality', + 'Hurting', + 'Joy', + 'Petulance', + 'Intrusion', + 'Chaos', + 'Suffering', + 'Extroversion', + 'Frenzy', + 'Solitude', + 'Punctuality', + 'Efficiency', + 'Comfort', + 'Patience', + 'Internment', + 'Incarceration', + 'Misapprehension', + 'Loyalty', + 'Envy', + 'Acrimony', + 'Worry', + 'Fear', + 'Awe', + 'Guile', + 'Prurience', + 'Fortune', + 'Perspicacity', + 'Domination', + 'Submission', + 'Fealty', + 'Hunger', + 'Despair', + 'Cruelty', + 'Grob', + 'Dignard', + 'Ra', + 'the Bone', + 'Diamonique', + 'Electrum', + 'Hydragyrum', + ], + 'Klasses': [ + 'Ur-Paladin|WIS,CON', + 'Voodoo Princess|INT,CHA', + 'Robot Monk|STR', + 'Mu-Fu Monk|DEX', + 'Mage Illusioner|INT,MP Max', + 'Shiv-Knight|DEX', + 'Inner Mason|CON', + 'Fighter/Organist|CHA,STR', + 'Puma Burgular|DEX', + 'Runeloremaster|WIS', + 'Hunter Strangler|DEX,INT', + 'Battle-Felon|STR', + 'Tickle-Mimic|WIS,INT', + 'Slow Poisoner|CON', + 'Bastard Lunatic|CON', + 'Jungle Clown|DEX,CHA', + 'Birdrider|WIS', + 'Vermineer|INT', + ], + 'MonMods': [ + '-4 fœtal *', + '-4 dying *', + '-3 crippled *', + '-3 baby *', + '-2 adolescent', + '-2 very sick *', + '-1 lesser *', + '-1 undernourished *', + '+1 greater *', + '+1 * Elder', + '+2 war *', + '+2 Battle-*', + '+3 Were-*', + '+3 undead *', + '+4 giant *', + '+4 * Rex', + ], + 'Monsters': [ + 'Anhkheg|6|chitin', + 'Ant|0|antenna', + 'Ape|4|ass', + 'Baluchitherium|14|ear', + 'Beholder|10|eyestalk', + 'Black Pudding|10|saliva', + 'Blink Dog|4|eyelid', + 'Cub Scout|1|neckerchief', + 'Girl Scout|2|cookie', + 'Boy Scout|3|merit badge', + 'Eagle Scout|4|merit badge', + 'Bugbear|3|skin', + 'Bugboar|3|tusk', + 'Boogie|3|slime', + 'Camel|2|hump', + 'Carrion Crawler|3|egg', + 'Catoblepas|6|neck', + 'Centaur|4|rib', + 'Centipede|0|leg', + 'Cockatrice|5|wattle', + 'Couatl|9|wing', + 'Crayfish|0|antenna', + 'Demogorgon|53|tentacle', + 'Jubilex|17|gel', + 'Manes|1|tooth', + 'Orcus|27|wand', + 'Succubus|6|bra', + 'Vrock|8|neck', + 'Hezrou|9|leg', + 'Glabrezu|10|collar', + 'Nalfeshnee|11|tusk', + 'Marilith|7|arm', + 'Balor|8|whip', + 'Yeenoghu|25|flail', + 'Asmodeus|52|leathers', + 'Baalzebul|43|pants', + 'Barbed Devil|8|flame', + 'Bone Devil|9|hook', + 'Dispater|30|matches', + 'Erinyes|6|thong', + 'Geryon|30|cornucopia', + 'Malebranche|5|fork', + 'Ice Devil|11|snow', + 'Lemure|3|blob', + 'Pit Fiend|13|seed', + 'Ankylosaurus|9|tail', + 'Brontosaurus|30|brain', + 'Diplodocus|24|fin', + 'Elasmosaurus|15|neck', + 'Gorgosaurus|13|arm', + 'Iguanadon|6|thumb', + 'Megalosaurus|12|jaw', + 'Monoclonius|8|horn', + 'Pentasaurus|12|head', + 'Stegosaurus|18|plate', + 'Triceratops|16|horn', + 'Tyrannosaurus Rex|18|forearm', + 'Djinn|7|lamp', + 'Doppelganger|4|face', + 'Black Dragon|7|*', + 'Plaid Dragon|7|sporrin', + 'Blue Dragon|9|*', + 'Beige Dragon|9|*', + 'Brass Dragon|7|pole', + 'Tin Dragon|8|*', + 'Bronze Dragon|9|medal', + 'Chromatic Dragon|16|scale', + 'Copper Dragon|8|loafer', + 'Gold Dragon|8|filling', + 'Green Dragon|8|*', + 'Platinum Dragon|21|*', + 'Red Dragon|10|cocktail', + 'Silver Dragon|10|*', + 'White Dragon|6|tooth', + 'Dragon Turtle|13|shell', + 'Dryad|2|acorn', + 'Dwarf|1|drawers', + 'Eel|2|sashimi', + 'Efreet|10|cinder', + 'Sand Elemental|8|glass', + 'Bacon Elemental|10|bit', + 'Porn Elemental|12|lube', + 'Cheese Elemental|14|curd', + 'Hair Elemental|16|follicle', + 'Swamp Elf|1|lilypad', + 'Brown Elf|1|tusk', + 'Sea Elf|1|jerkin', + 'Ettin|10|fur', + 'Frog|0|leg', + 'Violet Fungi|3|spore', + 'Gargoyle|4|gravel', + 'Gelatinous Cube|4|jam', + 'Ghast|4|vomit', + 'Ghost|10|*', + 'Ghoul|2|muscle', + 'Humidity Giant|12|drops', + 'Beef Giant|11|steak', + 'Quartz Giant|10|crystal', + 'Porcelain Giant|9|fixture', + 'Rice Giant|8|grain', + 'Cloud Giant|12|condensation', + 'Fire Giant|11|cigarettes', + 'Frost Giant|10|snowman', + 'Hill Giant|8|corpse', + 'Stone Giant|9|hatchling', + 'Storm Giant|15|barometer', + 'Mini Giant|4|pompadour', + 'Gnoll|2|collar', + 'Gnome|1|hat', + 'Goblin|1|ear', + 'Grid Bug|1|carapace', + 'Jellyrock|9|seedling', + 'Beer Golem|15|foam', + 'Oxygen Golem|17|platelet', + 'Cardboard Golem|14|recycling', + 'Rubber Golem|16|ball', + 'Leather Golem|15|fob', + 'Gorgon|8|testicle', + 'Gray Ooze|3|gravy', + 'Green Slime|2|sample', + 'Griffon|7|nest', + 'Banshee|7|larynx', + 'Harpy|3|mascara', + 'Hell Hound|5|tongue', + 'Hippocampus|4|mane', + 'Hippogriff|3|egg', + 'Hobgoblin|1|patella', + 'Homunculus|2|fluid', + 'Hydra|8|gyrum', + 'Imp|2|tail', + 'Invisible Stalker|8|*', + 'Iron Peasant|3|chaff', + 'Jumpskin|3|shin', + 'Kobold|1|penis', + 'Leprechaun|1|wallet', + 'Leucrotta|6|hoof', + 'Lich|11|crown', + 'Lizard Man|2|tail', + 'Lurker|10|sac', + 'Manticore|6|spike', + 'Mastodon|12|tusk', + 'Medusa|6|eye', + 'Multicell|2|dendrite', + 'Pirate|1|booty', + 'Berserker|1|shirt', + 'Caveman|2|club', + 'Dervish|1|robe', + 'Merman|1|trident', + 'Mermaid|1|gills', + 'Mimic|9|hinge', + 'Mind Flayer|8|tentacle', + 'Minotaur|6|map', + 'Yellow Mold|1|spore', + 'Morkoth|7|teeth', + 'Mummy|6|gauze', + 'Naga|9|rattle', + 'Nebbish|1|belly', + 'Neo-Otyugh|11|organ ', + 'Nixie|1|webbing', + 'Nymph|3|hanky', + 'Ochre Jelly|6|nucleus', + 'Octopus|2|beak', + 'Ogre|4|talon', + 'Ogre Mage|5|apparel', + 'Orc|1|snout', + 'Otyugh|7|organ', + 'Owlbear|5|feather', + 'Pegasus|4|aileron', + 'Peryton|4|antler', + 'Piercer|3|tip', + 'Pixie|1|dust', + 'Man-o-war|3|tentacle', + 'Purple Worm|15|dung', + 'Quasit|3|tail', + 'Rakshasa|7|pajamas', + 'Rat|0|tail', + 'Remorhaz|11|protrusion', + 'Roc|18|wing', + 'Roper|11|twine', + 'Rot Grub|1|eggsac', + 'Rust Monster|5|shavings', + 'Satyr|5|hoof', + 'Sea Hag|3|wart', + 'Silkie|3|fur', + 'Shadow|3|silhouette', + 'Shambling Mound|10|mulch', + 'Shedu|9|hoof', + 'Shrieker|3|stalk', + 'Skeleton|1|clavicle', + 'Spectre|7|vestige', + 'Sphinx|10|paw', + 'Spider|0|web', + 'Sprite|1|can', + 'Stirge|1|proboscis', + 'Stun Bear|5|tooth', + 'Stun Worm|2|trode', + 'Su-monster|5|tail', + 'Sylph|3|thigh', + 'Titan|20|sandal', + 'Trapper|12|shag', + 'Treant|10|acorn', + 'Triton|3|scale', + 'Troglodyte|2|tail', + 'Troll|6|hide', + 'Umber Hulk|8|claw', + 'Unicorn|4|blood', + 'Vampire|8|pancreas', + 'Wight|4|lung', + 'Will-o-the-Wisp|9|wisp', + 'Wraith|5|finger', + 'Wyvern|7|wing', + 'Xorn|7|jaw', + 'Yeti|4|fur', + 'Zombie|2|forehead', + 'Wasp|0|stinger', + 'Rat|1|tail', + 'Bunny|0|ear', + 'Moth|0|dust', + 'Beagle|0|collar', + 'Midge|0|corpse', + 'Ostrich|1|beak', + 'Billy Goat|1|beard', + 'Bat|1|wing', + 'Koala|2|heart', + 'Wolf|2|paw', + 'Whippet|2|collar', + 'Uruk|2|boot', + 'Poroid|4|node', + 'Moakum|8|frenum', + 'Fly|0|*', + 'Hogbird|3|curl', + ], + 'OffenseAttrib': [ + 'Polished|+1', + 'Serrated|+1', + 'Heavy|+1', + 'Pronged|+2', + 'Steely|+2', + 'Vicious|+3', + 'Venomed|+4', + 'Stabbity|+4', + 'Dancing|+5', + 'Invisible|+6', + 'Vorpal|+7', + ], + 'OffenseBad': [ + 'Dull|-2', + 'Tarnished|-1', + 'Rusty|-3', + 'Padded|-5', + 'Bent|-4', + 'Mini|-4', + 'Rubber|-6', + 'Nerf|-7', + 'Unbalanced|-2', + ], + 'Races': [ + 'Half Orc|HP Max', + 'Half Man|CHA', + 'Half Halfling|DEX', + 'Double Hobbit|STR', + 'Hob-Hobbit|DEX,CON', + 'Low Elf|CON', + 'Dung Elf|WIS', + 'Talking Pony|MP Max,INT', + 'Gyrognome|DEX', + 'Lesser Dwarf|CON', + 'Crested Dwarf|CHA', + 'Eel Man|DEX', + 'Panda Man|CON,STR', + 'Trans-Kobold|WIS', + 'Enchanted Motorcycle|MP Max', + "Will o' the Wisp|WIS", + 'Battle-Finch|DEX,INT', + 'Double Wookiee|STR', + 'Skraeling|WIS', + 'Demicanadian|CON', + 'Land Squid|STR,HP Max', + ], + 'Shields': [ + 'Parasol|0', + 'Pie Plate|1', + 'Garbage Can Lid|2', + 'Buckler|3', + 'Plexiglass|4', + 'Fender|4', + 'Round Shield|5', + 'Carapace|5', + 'Scutum|6', + 'Propugner|6', + 'Kite Shield|7', + 'Pavise|8', + 'Tower Shield|9', + 'Baroque Shield|11', + 'Aegis|12', + 'Magnetic Field|18', + ], + 'Specials': [ + 'Diadem', + 'Festoon', + 'Gemstone', + 'Phial', + 'Tiara', + 'Scabbard', + 'Arrow', + 'Lens', + 'Lamp', + 'Hymnal', + 'Fleece', + 'Laurel', + 'Brooch', + 'Gimlet', + 'Cobble', + 'Albatross', + 'Brazier', + 'Bandolier', + 'Tome', + 'Garnet', + 'Amethyst', + 'Candelabra', + 'Corset', + 'Sphere', + 'Sceptre', + 'Ankh', + 'Talisman', + 'Orb', + 'Gammel', + 'Ornament', + 'Brocade', + 'Galoon', + 'Bijou', + 'Spangle', + 'Gimcrack', + 'Hood', + 'Vulpeculum', + ], + 'Spells': [ + 'Slime Finger', + 'Rabbit Punch', + 'Hastiness', + 'Good Move', + 'Sadness', + 'Seasick', + 'Shoelaces', + 'Inoculate', + 'Cone of Annoyance', + 'Magnetic Orb', + 'Invisible Hands', + 'Revolting Cloud', + 'Aqueous Humor', + 'Spectral Miasma', + 'Clever Fellow', + 'Lockjaw', + 'History Lesson', + 'Hydrophobia', + 'Big Sister', + 'Cone of Paste', + 'Mulligan', + "Nestor's Bright Idea", + 'Holy Batpole', + 'Tumor (Benign)', + 'Braingate', + 'Nonplus', + 'Animate Nightstand', + 'Eye of the Troglodyte', + 'Curse Name', + 'Dropsy', + 'Vitreous Humor', + "Roger's Grand Illusion", + 'Covet', + 'Astral Miasma', + 'Spectral Oyster', + 'Acrid Hands', + 'Angioplasty', + "Grognor's Big Day Off", + 'Tumor (Malignant)', + 'Animate Tunic', + 'Ursine Armor', + 'Holy Roller', + 'Tonsillectomy', + 'Curse Family', + 'Infinite Confusion', + ], + 'Titles': [ + 'Mr.', + 'Mrs.', + 'Sir', + 'Sgt.', + 'Ms.', + 'Captain', + 'Chief', + 'Admiral', + 'Saint', + ], + 'Weapons': [ + 'Stick|0', + 'Broken Bottle|1', + 'Shiv|1', + 'Sprig|1', + 'Oxgoad|1', + 'Eelspear|2', + 'Bowie Knife|2', + 'Claw Hammer|2', + 'Handpeen|2', + 'Andiron|3', + 'Hatchet|3', + 'Tomahawk|3', + 'Hackbarm|3', + 'Crowbar|4', + 'Mace|4', + 'Battleadze|4', + 'Leafmace|5', + 'Shortsword|5', + 'Longiron|5', + 'Poachard|5', + 'Baselard|5', + 'Whinyard|6', + 'Blunderbuss|6', + 'Longsword|6', + 'Crankbow|6', + 'Blibo|7', + 'Broadsword|7', + 'Kreen|7', + 'Morning Star|8', + 'Pole-adze|8', + 'Spontoon|8', + 'Bastard Sword|9', + 'Peen-arm|9', + 'Culverin|10', + 'Lance|10', + 'Halberd|11', + 'Poleax|12', + 'Bandyclef|15', + ], +}; diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..be2deb6 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,6 @@ +import 'package:askiineverdie/src/app.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(const AskiiNeverDieApp()); +} diff --git a/lib/src/app.dart b/lib/src/app.dart new file mode 100644 index 0000000..d44bbdb --- /dev/null +++ b/lib/src/app.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/src/core/engine/game_mutations.dart'; +import 'package:askiineverdie/src/core/engine/progress_service.dart'; +import 'package:askiineverdie/src/core/engine/reward_service.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/storage/save_manager.dart'; +import 'package:askiineverdie/src/core/storage/save_repository.dart'; +import 'package:askiineverdie/src/features/front/front_screen.dart'; +import 'package:askiineverdie/src/features/front/save_picker_dialog.dart'; +import 'package:askiineverdie/src/features/game/game_play_screen.dart'; +import 'package:askiineverdie/src/features/game/game_session_controller.dart'; +import 'package:askiineverdie/src/features/new_character/new_character_screen.dart'; + +class AskiiNeverDieApp extends StatefulWidget { + const AskiiNeverDieApp({super.key}); + + @override + State createState() => _AskiiNeverDieAppState(); +} + +class _AskiiNeverDieAppState extends State { + late final GameSessionController _controller; + + @override + void initState() { + super.initState(); + const config = PqConfig(); + final mutations = GameMutations(config); + final rewards = RewardService(mutations); + + _controller = GameSessionController( + progressService: ProgressService( + config: config, + mutations: mutations, + rewards: rewards, + ), + saveManager: SaveManager(SaveRepository()), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Ascii Never Die', + debugShowCheckedModeBanner: false, + theme: ThemeData( + colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF234361)), + scaffoldBackgroundColor: const Color(0xFFF4F5F7), + useMaterial3: true, + ), + home: FrontScreen( + onNewCharacter: _navigateToNewCharacter, + onLoadSave: _loadSave, + ), + ); + } + + void _navigateToNewCharacter(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => NewCharacterScreen( + onCharacterCreated: (initialState) { + _startGame(context, initialState); + }, + ), + ), + ); + } + + Future _loadSave(BuildContext context) async { + // 저장 파일 목록 조회 + final saves = await _controller.saveManager.listSaves(); + + if (!context.mounted) return; + + String? selectedFileName; + + if (saves.isEmpty) { + // 저장 파일이 없으면 안내 메시지 + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.'))); + return; + } else if (saves.length == 1) { + // 파일이 하나면 바로 선택 + selectedFileName = saves.first.fileName; + } else { + // 여러 개면 다이얼로그 표시 + selectedFileName = await SavePickerDialog.show(context, saves); + } + + if (selectedFileName == null || !context.mounted) return; + + // 선택된 파일 로드 + await _controller.loadAndStart( + fileName: selectedFileName, + cheatsEnabled: false, + ); + + if (_controller.status == GameSessionStatus.running) { + if (context.mounted) { + _navigateToGame(context); + } + } else if (_controller.status == GameSessionStatus.error) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '저장 파일을 불러올 수 없습니다: ${_controller.error ?? "알 수 없는 오류"}', + ), + ), + ); + } + } + } + + Future _startGame(BuildContext context, GameState initialState) async { + await _controller.startNew(initialState, cheatsEnabled: false); + + if (context.mounted) { + // NewCharacterScreen을 pop하고 GamePlayScreen으로 이동 + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => GamePlayScreen(controller: _controller), + ), + ); + } + } + + void _navigateToGame(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => GamePlayScreen(controller: _controller), + ), + ); + } +} diff --git a/lib/src/core/engine/game_mutations.dart b/lib/src/core/engine/game_mutations.dart new file mode 100644 index 0000000..b3e4ff7 --- /dev/null +++ b/lib/src/core/engine/game_mutations.dart @@ -0,0 +1,86 @@ +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; + +/// Game state mutations that mirror the original PQ win/reward logic. +class GameMutations { + const GameMutations(this.config); + + final PqConfig config; + + GameState winEquip(GameState state, int level, EquipmentSlot slot) { + final rng = state.rng; + final name = pq_logic.winEquip(config, rng, level, slot); + final equip = state.equipment; + final updatedEquip = switch (slot) { + EquipmentSlot.weapon => equip.copyWith( + weapon: name, + bestIndex: EquipmentSlot.weapon.index, + ), + EquipmentSlot.shield => equip.copyWith( + shield: name, + bestIndex: EquipmentSlot.shield.index, + ), + EquipmentSlot.armor => equip.copyWith( + armor: name, + bestIndex: EquipmentSlot.armor.index, + ), + }; + return state.copyWith(rng: rng, equipment: updatedEquip); + } + + GameState winStat(GameState state) { + final updatedStats = pq_logic.winStat(state.stats, state.rng); + return state.copyWith(rng: state.rng, stats: updatedStats); + } + + GameState winSpell(GameState state, int wisdom, int level) { + final result = pq_logic.winSpell(config, state.rng, wisdom, level); + final parts = result.split('|'); + final name = parts[0]; + final rank = parts.length > 1 ? parts[1] : 'I'; + + final spells = [...state.spellBook.spells]; + final index = spells.indexWhere((s) => s.name == name); + if (index >= 0) { + spells[index] = spells[index].copyWith(rank: rank); + } else { + spells.add(SpellEntry(name: name, rank: rank)); + } + + return state.copyWith( + rng: state.rng, + spellBook: state.spellBook.copyWith(spells: spells), + ); + } + + GameState winItem(GameState state) { + final rng = state.rng; + final result = pq_logic.winItem(config, rng, state.inventory.items.length); + final items = [...state.inventory.items]; + + if (result.isEmpty) { + // Duplicate an existing item if possible. + if (items.isNotEmpty) { + final pickIndex = rng.nextInt(items.length); + final picked = items[pickIndex]; + items[pickIndex] = picked.copyWith(count: picked.count + 1); + } + } else { + final existing = items.indexWhere((e) => e.name == result); + if (existing >= 0) { + items[existing] = items[existing].copyWith( + count: items[existing].count + 1, + ); + } else { + items.add(InventoryEntry(name: result, count: 1)); + } + } + + return state.copyWith( + rng: rng, + inventory: state.inventory.copyWith(items: items), + ); + } +} diff --git a/lib/src/core/engine/progress_loop.dart b/lib/src/core/engine/progress_loop.dart new file mode 100644 index 0000000..d17c332 --- /dev/null +++ b/lib/src/core/engine/progress_loop.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/storage/save_manager.dart'; +import 'package:askiineverdie/src/core/engine/progress_service.dart'; + +class AutoSaveConfig { + const AutoSaveConfig({ + this.onLevelUp = true, + this.onQuestComplete = true, + this.onActComplete = true, + this.onStop = true, + }); + + final bool onLevelUp; + final bool onQuestComplete; + final bool onActComplete; + final bool onStop; + + bool shouldSave(ProgressTickResult result) { + return (onLevelUp && result.leveledUp) || + (onQuestComplete && result.completedQuest) || + (onActComplete && result.completedAct); + } +} + +/// Runs the periodic timer loop that advances tasks/quests/plots. +class ProgressLoop { + ProgressLoop({ + required GameState initialState, + required this.progressService, + this.saveManager, + Duration tickInterval = const Duration(milliseconds: 50), + AutoSaveConfig autoSaveConfig = const AutoSaveConfig(), + DateTime Function()? now, + this.cheatsEnabled = false, + }) : _state = initialState, + _tickInterval = tickInterval, + _autoSaveConfig = autoSaveConfig, + _now = now ?? DateTime.now, + _stateController = StreamController.broadcast(); + + final ProgressService progressService; + final SaveManager? saveManager; + final Duration _tickInterval; + final AutoSaveConfig _autoSaveConfig; + final DateTime Function() _now; + final StreamController _stateController; + bool cheatsEnabled; + + Timer? _timer; + int? _lastTickMs; + int _speedMultiplier = 1; + + GameState get current => _state; + Stream get stream => _stateController.stream; + GameState _state; + + /// 현재 배속 (1x, 2x, 5x) + int get speedMultiplier => _speedMultiplier; + + /// 배속 순환: 1 -> 2 -> 5 -> 1 + void cycleSpeed() { + _speedMultiplier = switch (_speedMultiplier) { + 1 => 2, + 2 => 5, + _ => 1, + }; + } + + void start() { + _lastTickMs = _now().millisecondsSinceEpoch; + _timer ??= Timer.periodic(_tickInterval, (_) => tickOnce()); + } + + Future stop({bool saveOnStop = false}) async { + _timer?.cancel(); + _timer = null; + if (saveOnStop && _autoSaveConfig.onStop && saveManager != null) { + await saveManager!.saveState(_state); + } + } + + void dispose() { + _timer?.cancel(); + _stateController.close(); + } + + /// Run one iteration of the loop (used by Timer or manual stepping). + GameState tickOnce({int? deltaMillis}) { + final baseDelta = deltaMillis ?? _computeDelta(); + final delta = baseDelta * _speedMultiplier; + final result = progressService.tick(_state, delta); + _state = result.state; + _stateController.add(_state); + + if (saveManager != null && _autoSaveConfig.shouldSave(result)) { + saveManager!.saveState(_state); + } + return _state; + } + + /// Replace state (e.g., after loading) and reset timing. + void replaceState(GameState newState) { + _state = newState; + _stateController.add(newState); + _lastTickMs = _now().millisecondsSinceEpoch; + } + + // Developer-only helpers mirroring original cheat panel actions. + void cheatCompleteTask() { + if (!cheatsEnabled) return; + _state = progressService.forceTaskComplete(_state); + _stateController.add(_state); + } + + void cheatCompleteQuest() { + if (!cheatsEnabled) return; + _state = progressService.forceQuestComplete(_state); + _stateController.add(_state); + } + + void cheatCompletePlot() { + if (!cheatsEnabled) return; + _state = progressService.forcePlotComplete(_state); + _stateController.add(_state); + } + + int _computeDelta() { + final nowMs = _now().millisecondsSinceEpoch; + final last = _lastTickMs; + _lastTickMs = nowMs; + if (last == null) return 0; + final delta = nowMs - last; + if (delta < 0) return 0; + return delta; + } +} diff --git a/lib/src/core/engine/progress_service.dart b/lib/src/core/engine/progress_service.dart new file mode 100644 index 0000000..1dbaeb7 --- /dev/null +++ b/lib/src/core/engine/progress_service.dart @@ -0,0 +1,712 @@ +import 'dart:math' as math; + +import 'package:askiineverdie/src/core/engine/game_mutations.dart'; +import 'package:askiineverdie/src/core/engine/reward_service.dart'; +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; + +class ProgressTickResult { + const ProgressTickResult({ + required this.state, + this.leveledUp = false, + this.completedQuest = false, + this.completedAct = false, + }); + + final GameState state; + final bool leveledUp; + final bool completedQuest; + final bool completedAct; + + bool get shouldAutosave => leveledUp || completedQuest || completedAct; +} + +/// Drives quest/plot/task progression by applying queued actions and rewards. +class ProgressService { + ProgressService({ + required this.config, + required this.mutations, + required this.rewards, + }); + + final PqConfig config; + final GameMutations mutations; + final RewardService rewards; + + /// 새 게임 초기화 (원본 GoButtonClick, Main.pas:741-767) + /// Prologue 태스크들을 큐에 추가하고 첫 태스크 시작 + GameState initializeNewGame(GameState state) { + // 초기 큐 설정 (원본 753-757줄) + final initialQueue = [ + const QueueEntry( + kind: QueueKind.task, + durationMillis: 10 * 1000, + caption: 'Experiencing an enigmatic and foreboding night vision', + taskType: TaskType.load, + ), + const QueueEntry( + kind: QueueKind.task, + durationMillis: 6 * 1000, + caption: "Much is revealed about that wise old bastard you'd " + 'underestimated', + taskType: TaskType.load, + ), + const QueueEntry( + kind: QueueKind.task, + durationMillis: 6 * 1000, + caption: 'A shocking series of events leaves you alone and bewildered, ' + 'but resolute', + taskType: TaskType.load, + ), + const QueueEntry( + kind: QueueKind.task, + durationMillis: 4 * 1000, + caption: 'Drawing upon an unexpected reserve of determination, ' + 'you set out on a long and dangerous journey', + taskType: TaskType.load, + ), + const QueueEntry( + kind: QueueKind.plot, + durationMillis: 2 * 1000, + caption: 'Loading', + taskType: TaskType.plot, + ), + ]; + + // 첫 번째 태스크 'Loading' 시작 (원본 752줄) + final taskResult = pq_logic.startTask( + state.progress, + 'Loading', + 2 * 1000, + ); + + // ExpBar 초기화 (원본 743-746줄) + final expBar = ProgressBarState( + position: 0, + max: pq_logic.levelUpTime(1), + ); + + // PlotBar 초기화 (원본 759줄) + final plotBar = const ProgressBarState(position: 0, max: 26 * 1000); + + final progress = taskResult.progress.copyWith( + exp: expBar, + plot: plotBar, + currentTask: const TaskInfo(caption: 'Loading...', type: TaskType.load), + plotStageCount: 1, // Prologue + questCount: 0, + ); + + return _recalculateEncumbrance( + state.copyWith( + progress: progress, + queue: QueueState(entries: initialQueue), + ), + ); + } + + /// Starts a task and tags its type (kill, plot, load, neutral). + GameState startTask( + GameState state, { + required String caption, + required int durationMillis, + TaskType taskType = TaskType.neutral, + }) { + final taskResult = pq_logic.startTask( + state.progress, + caption, + durationMillis, + ); + final progress = taskResult.progress.copyWith( + currentTask: TaskInfo(caption: taskResult.caption, type: taskType), + ); + return state.copyWith(progress: progress); + } + + /// Tick the timer loop (equivalent to Timer1Timer in the original code). + ProgressTickResult tick(GameState state, int elapsedMillis) { + final int clamped = elapsedMillis.clamp(0, 100).toInt(); + var progress = state.progress; + var queue = state.queue; + var nextState = state; + var leveledUp = false; + var questDone = false; + var actDone = false; + + // Advance task bar if still running. + if (progress.task.position < progress.task.max) { + final uncapped = progress.task.position + clamped; + final int newTaskPos = uncapped > progress.task.max + ? progress.task.max + : uncapped; + progress = progress.copyWith( + task: progress.task.copyWith(position: newTaskPos), + ); + nextState = _recalculateEncumbrance( + nextState.copyWith(progress: progress), + ); + return ProgressTickResult(state: nextState); + } + + final gain = progress.currentTask.type == TaskType.kill; + final incrementSeconds = progress.task.max ~/ 1000; + + // 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630) + if (gain) { + nextState = _winLoot(nextState); + progress = nextState.progress; + } + + // 시장/판매/구매 태스크 완료 시 처리 (원본 Main.pas:631-649) + final taskType = progress.currentTask.type; + if (taskType == TaskType.buying) { + // 장비 구매 완료 (원본 631-634) + nextState = _completeBuying(nextState); + progress = nextState.progress; + } else if (taskType == TaskType.market || taskType == TaskType.sell) { + // 시장 도착 또는 판매 완료 (원본 635-649) + final sellResult = _processSell(nextState); + nextState = sellResult.state; + progress = nextState.progress; + queue = nextState.queue; + + // 판매 중이면 다른 로직 건너뛰기 + if (sellResult.continuesSelling) { + nextState = _recalculateEncumbrance( + nextState.copyWith(progress: progress, queue: queue), + ); + return ProgressTickResult( + state: nextState, + leveledUp: false, + completedQuest: false, + completedAct: false, + ); + } + } + + // Gain XP / level up. + if (gain) { + if (progress.exp.position >= progress.exp.max) { + nextState = _levelUp(nextState); + leveledUp = true; + progress = nextState.progress; + } else { + final uncappedExp = progress.exp.position + incrementSeconds; + final int newExpPos = uncappedExp > progress.exp.max + ? progress.exp.max + : uncappedExp; + progress = progress.copyWith( + exp: progress.exp.copyWith(position: newExpPos), + ); + } + } + + // Advance quest bar after Act I. + final canQuestProgress = + gain && + progress.plotStageCount > 1 && + progress.questCount > 0 && + progress.quest.max > 0; + if (canQuestProgress) { + if (progress.quest.position + incrementSeconds >= progress.quest.max) { + nextState = completeQuest(nextState); + questDone = true; + progress = nextState.progress; + queue = nextState.queue; + } else { + progress = progress.copyWith( + quest: progress.quest.copyWith( + position: progress.quest.position + incrementSeconds, + ), + ); + } + } + + // 플롯(plot) 바가 완료되면 InterplotCinematic 트리거 + // (원본 Main.pas:1301-1304) + if (gain && + progress.plot.max > 0 && + progress.plot.position >= progress.plot.max) { + // InterplotCinematic을 호출하여 시네마틱 이벤트 큐에 추가 + final cinematicEntries = pq_logic.interplotCinematic( + config, + nextState.rng, + nextState.traits.level, + nextState.progress.plotStageCount, + ); + queue = QueueState(entries: [...queue.entries, ...cinematicEntries]); + // 플롯 바를 0으로 리셋하지 않음 - completeAct에서 처리됨 + } else if (progress.currentTask.type != TaskType.load && + progress.plot.max > 0) { + final uncappedPlot = progress.plot.position + incrementSeconds; + final int newPlotPos = uncappedPlot > progress.plot.max + ? progress.plot.max + : uncappedPlot; + progress = progress.copyWith( + plot: progress.plot.copyWith(position: newPlotPos), + ); + } + + // Dequeue next scripted task if available. + final dq = pq_logic.dequeue(progress, queue); + if (dq != null) { + progress = dq.progress.copyWith( + currentTask: TaskInfo(caption: dq.caption, type: dq.taskType), + ); + queue = dq.queue; + + // plot 타입이 dequeue 되면 completeAct 실행 (원본 Main.pas 로직) + if (dq.kind == QueueKind.plot) { + nextState = nextState.copyWith(progress: progress, queue: queue); + nextState = completeAct(nextState); + actDone = true; + progress = nextState.progress; + queue = nextState.queue; + } + } else { + // 큐가 비어있으면 새 태스크 생성 (원본 Dequeue 667-684줄) + nextState = nextState.copyWith(progress: progress, queue: queue); + final newTaskResult = _generateNextTask(nextState); + progress = newTaskResult.progress; + queue = newTaskResult.queue; + } + + nextState = _recalculateEncumbrance( + nextState.copyWith(progress: progress, queue: queue), + ); + + return ProgressTickResult( + state: nextState, + leveledUp: leveledUp, + completedQuest: questDone, + completedAct: actDone, + ); + } + + /// 큐가 비어있을 때 다음 태스크 생성 (원본 Dequeue 667-684줄) + ({ProgressState progress, QueueState queue}) _generateNextTask( + GameState state, + ) { + var progress = state.progress; + final queue = state.queue; + final oldTaskType = progress.currentTask.type; + + // 1. Encumbrance가 가득 찼으면 시장으로 이동 (원본 667-669줄) + if (progress.encumbrance.position >= progress.encumbrance.max && + progress.encumbrance.max > 0) { + final taskResult = pq_logic.startTask( + progress, + 'Heading to market to sell loot', + 4 * 1000, + ); + progress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.market, + ), + ); + return (progress: progress, queue: queue); + } + + // 2. kill 태스크가 아니었고 heading도 아니면 heading 또는 buying 태스크 실행 + // (원본 670-677줄) + if (oldTaskType != TaskType.kill && oldTaskType != TaskType.neutral) { + // Gold가 충분하면 장비 구매 (원본 671-673줄) + final gold = _getGold(state); + final equipPrice = _equipPrice(state.traits.level); + if (gold > equipPrice) { + final taskResult = pq_logic.startTask( + progress, + 'Negotiating purchase of better equipment', + 5 * 1000, + ); + progress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.buying, + ), + ); + return (progress: progress, queue: queue); + } + + // Gold가 부족하면 전장으로 이동 (원본 674-676줄) + final taskResult = pq_logic.startTask( + progress, + 'Heading to the killing fields', + 4 * 1000, + ); + progress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.neutral, + ), + ); + return (progress: progress, queue: queue); + } + + // 3. MonsterTask 실행 (원본 678-684줄) + final level = state.traits.level; + final monster = pq_logic.monsterTask( + config, + state.rng, + level, + null, // questMonster + null, // questLevel + ); + + // 태스크 지속시간 계산 (원본 682줄) + // n := (2 * InventoryLabelAlsoGameStyle.Tag * n * 1000) div l; + // InventoryLabelAlsoGameStyle.Tag는 게임 스타일을 나타내는 값 (1이 기본) + const gameStyleTag = 1; + final durationMillis = (2 * gameStyleTag * level * 1000) ~/ level; + + final taskResult = pq_logic.startTask( + progress, + 'Executing $monster', + durationMillis, + ); + + progress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.kill, + ), + ); + + return (progress: progress, queue: queue); + } + + /// Advances quest completion, applies reward, and enqueues next quest task. + GameState completeQuest(GameState state) { + final result = pq_logic.completeQuest( + config, + state.rng, + state.traits.level, + ); + + var nextState = _applyReward(state, result.reward); + final questCount = nextState.progress.questCount + 1; + + // Append quest entry to queue (task kind). + final updatedQueue = QueueState( + entries: [ + ...nextState.queue.entries, + QueueEntry( + kind: QueueKind.task, + durationMillis: 50 + nextState.rng.nextInt(100), + caption: result.caption, + taskType: TaskType.neutral, + ), + ], + ); + + // Update quest progress bar with reset position. + final progress = nextState.progress.copyWith( + quest: ProgressBarState( + position: 0, + max: 50 + nextState.rng.nextInt(100), + ), + questCount: questCount, + ); + + return _recalculateEncumbrance( + nextState.copyWith(progress: progress, queue: updatedQueue), + ); + } + + /// Advances plot to next act and applies any act-level rewards. + GameState completeAct(GameState state) { + final actResult = pq_logic.completeAct(state.progress.plotStageCount); + var nextState = state; + for (final reward in actResult.rewards) { + nextState = _applyReward(nextState, reward); + } + + final plotStages = nextState.progress.plotStageCount + 1; + var updatedProgress = nextState.progress.copyWith( + plot: ProgressBarState(position: 0, max: actResult.plotBarMaxSeconds), + plotStageCount: plotStages, + ); + + nextState = nextState.copyWith(progress: updatedProgress); + + // Act I 완료 후(Prologue -> Act I) 첫 퀘스트 시작 (원본 Main.pas 로직) + // plotStages == 2는 Prologue(1) -> Act I(2) 전환을 의미 + if (plotStages == 2) { + nextState = _startFirstQuest(nextState); + } + + return _recalculateEncumbrance(nextState); + } + + /// 첫 퀘스트 시작 (Act I 시작 시) + GameState _startFirstQuest(GameState state) { + final result = pq_logic.completeQuest( + config, + state.rng, + state.traits.level, + ); + + // 퀘스트 바 초기화 + final questBar = ProgressBarState( + position: 0, + max: 50 + state.rng.nextInt(100), + ); + + // 첫 퀘스트 추가 + final updatedQueue = QueueState( + entries: [ + ...state.queue.entries, + QueueEntry( + kind: QueueKind.task, + durationMillis: 50 + state.rng.nextInt(100), + caption: result.caption, + taskType: TaskType.neutral, + ), + ], + ); + + final progress = state.progress.copyWith( + quest: questBar, + questCount: 1, + ); + + return state.copyWith(progress: progress, queue: updatedQueue); + } + + /// Developer-only cheat hooks for quickly finishing bars. + GameState forceTaskComplete(GameState state) { + final progress = state.progress.copyWith( + task: state.progress.task.copyWith(position: state.progress.task.max), + ); + return state.copyWith(progress: progress); + } + + GameState forceQuestComplete(GameState state) { + final progress = state.progress.copyWith( + task: state.progress.task.copyWith(position: state.progress.task.max), + quest: state.progress.quest.copyWith(position: state.progress.quest.max), + ); + return state.copyWith(progress: progress); + } + + GameState forcePlotComplete(GameState state) { + final progress = state.progress.copyWith( + task: state.progress.task.copyWith(position: state.progress.task.max), + plot: state.progress.plot.copyWith(position: state.progress.plot.max), + ); + return state.copyWith(progress: progress); + } + + GameState _applyReward(GameState state, pq_logic.RewardKind reward) { + final updated = rewards.applyReward(state, reward); + return _recalculateEncumbrance(updated); + } + + GameState _levelUp(GameState state) { + final nextLevel = state.traits.level + 1; + final rng = state.rng; + final hpGain = state.stats.con ~/ 3 + 1 + rng.nextInt(4); + final mpGain = state.stats.intelligence ~/ 3 + 1 + rng.nextInt(4); + + var nextState = state.copyWith( + traits: state.traits.copyWith(level: nextLevel), + stats: state.stats.copyWith( + hpMax: state.stats.hpMax + hpGain, + mpMax: state.stats.mpMax + mpGain, + ), + ); + + // Win two stats and a spell, matching the original leveling rules. + nextState = mutations.winStat(nextState); + nextState = mutations.winStat(nextState); + nextState = mutations.winSpell(nextState, nextState.stats.wis, nextLevel); + + final expBar = ProgressBarState( + position: 0, + max: pq_logic.levelUpTime(nextLevel), + ); + final progress = nextState.progress.copyWith(exp: expBar); + nextState = nextState.copyWith(progress: progress); + return _recalculateEncumbrance(nextState); + } + + GameState _recalculateEncumbrance(GameState state) { + // items에는 Gold가 포함되지 않음 (inventory.gold 필드로 관리) + final encumValue = state.inventory.items.fold( + 0, + (sum, item) => sum + item.count, + ); + final encumMax = 10 + state.stats.str; + final encumBar = state.progress.encumbrance.copyWith( + position: encumValue, + max: encumMax, + ); + final progress = state.progress.copyWith(encumbrance: encumBar); + return state.copyWith(progress: progress); + } + + /// 킬 태스크 완료 시 전리품 획득 (원본 Main.pas:625-630) + GameState _winLoot(GameState state) { + final taskCaption = state.progress.currentTask.caption; + + // 몬스터 이름에서 전리품 아이템 생성 + // 원본: Add(Inventory, LowerCase(Split(fTask.Caption,1) + ' ' + + // ProperCase(Split(fTask.Caption,3))), 1); + // 예: "Executing a Goblin..." -> "goblin ear" 등의 아이템 + + // 태스크 캡션에서 몬스터 이름 추출 ("Executing ..." 형태) + String monsterName = taskCaption; + if (monsterName.startsWith('Executing ')) { + monsterName = monsterName.substring('Executing '.length); + } + if (monsterName.endsWith('...')) { + monsterName = monsterName.substring(0, monsterName.length - 3); + } + + // 몬스터 부위 선택 (원본에서는 몬스터별로 다르지만, 간단히 랜덤 선택) + final parts = ['Skin', 'Tooth', 'Claw', 'Ear', 'Eye', 'Tail', 'Scale']; + final part = pq_logic.pick(parts, state.rng); + + // 아이템 이름 생성 (예: "Goblin Ear") + final itemName = '${_extractBaseName(monsterName)} $part'; + + // 인벤토리에 추가 + final items = [...state.inventory.items]; + final existing = items.indexWhere((e) => e.name == itemName); + if (existing >= 0) { + items[existing] = items[existing].copyWith( + count: items[existing].count + 1, + ); + } else { + items.add(InventoryEntry(name: itemName, count: 1)); + } + + return state.copyWith( + inventory: state.inventory.copyWith(items: items), + ); + } + + /// 몬스터 이름에서 기본 이름 추출 (형용사 제거) + String _extractBaseName(String name) { + // "a Goblin", "an Orc", "2 Goblins" 등에서 기본 이름 추출 + final words = name.split(' '); + if (words.isEmpty) return name; + + // 관사나 숫자 제거 + var startIndex = 0; + if (words[0] == 'a' || words[0] == 'an' || words[0] == 'the') { + startIndex = 1; + } else if (int.tryParse(words[0]) != null) { + startIndex = 1; + } + + if (startIndex >= words.length) return name; + + // 마지막 단어가 몬스터 이름 (형용사들 건너뛰기) + final baseName = words.last; + // 첫 글자 대문자로 + if (baseName.isEmpty) return name; + return baseName[0].toUpperCase() + baseName.substring(1).toLowerCase(); + } + + /// 인벤토리에서 Gold 수량 반환 + int _getGold(GameState state) { + return state.inventory.gold; + } + + /// 장비 가격 계산 (원본 Main.pas:612-616) + /// Result := 5 * Level^2 + 10 * Level + 20 + int _equipPrice(int level) { + return 5 * level * level + 10 * level + 20; + } + + /// 장비 구매 완료 처리 (원본 Main.pas:631-634) + GameState _completeBuying(GameState state) { + final level = state.traits.level; + final price = _equipPrice(level); + + // Gold 차감 (inventory.gold 필드 사용) + final newGold = math.max(0, state.inventory.gold - price); + var nextState = state.copyWith( + inventory: state.inventory.copyWith(gold: newGold), + ); + + // 장비 획득 (WinEquip) + nextState = mutations.winEquip( + nextState, + level, + EquipmentSlot.values[nextState.rng.nextInt(EquipmentSlot.values.length)], + ); + + return nextState; + } + + /// 판매 처리 결과 + ({GameState state, bool continuesSelling}) _processSell(GameState state) { + final taskType = state.progress.currentTask.type; + var items = [...state.inventory.items]; + var goldAmount = state.inventory.gold; + + // sell 태스크 완료 시 아이템 판매 (원본 Main.pas:636-643) + if (taskType == TaskType.sell) { + // 첫 번째 아이템 찾기 (items에는 Gold가 없음) + if (items.isNotEmpty) { + final item = items.first; + final level = state.traits.level; + + // 가격 계산: 수량 * 레벨 + var price = item.count * level; + + // " of " 포함 시 보너스 (원본 639-640) + if (item.name.contains(' of ')) { + price = price * + (1 + pq_logic.randomLow(state.rng, 10)) * + (1 + pq_logic.randomLow(state.rng, level)); + } + + // 아이템 삭제 + items.removeAt(0); + + // Gold 추가 (inventory.gold 필드 사용) + goldAmount += price; + } + } + + // 판매할 아이템이 남아있는지 확인 + final hasItemsToSell = items.isNotEmpty; + + if (hasItemsToSell) { + // 다음 아이템 판매 태스크 시작 + final nextItem = items.first; + final taskResult = pq_logic.startTask( + state.progress, + 'Selling ${pq_logic.indefinite(nextItem.name, nextItem.count)}', + 1 * 1000, + ); + final progress = taskResult.progress.copyWith( + currentTask: TaskInfo( + caption: taskResult.caption, + type: TaskType.sell, + ), + ); + return ( + state: state.copyWith( + inventory: state.inventory.copyWith(gold: goldAmount, items: items), + progress: progress, + ), + continuesSelling: true, + ); + } + + // 판매 완료 - 인벤토리 업데이트만 하고 다음 태스크로 + return ( + state: state.copyWith( + inventory: state.inventory.copyWith(gold: goldAmount, items: items), + ), + continuesSelling: false, + ); + } +} diff --git a/lib/src/core/engine/reward_service.dart b/lib/src/core/engine/reward_service.dart new file mode 100644 index 0000000..71e0db4 --- /dev/null +++ b/lib/src/core/engine/reward_service.dart @@ -0,0 +1,26 @@ +import 'package:askiineverdie/src/core/engine/game_mutations.dart'; +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/util/pq_logic.dart'; + +/// Applies quest/act rewards to the GameState using shared RNG. +class RewardService { + RewardService(this.mutations); + + final GameMutations mutations; + + GameState applyReward(GameState state, RewardKind reward) { + switch (reward) { + case RewardKind.spell: + return mutations.winSpell(state, state.stats.wis, state.traits.level); + case RewardKind.equip: + final slot = EquipmentSlot + .values[state.rng.nextInt(EquipmentSlot.values.length)]; + return mutations.winEquip(state, state.traits.level, slot); + case RewardKind.stat: + return mutations.winStat(state); + case RewardKind.item: + return mutations.winItem(state); + } + } +} diff --git a/lib/src/core/model/equipment_slot.dart b/lib/src/core/model/equipment_slot.dart new file mode 100644 index 0000000..8ae1500 --- /dev/null +++ b/lib/src/core/model/equipment_slot.dart @@ -0,0 +1 @@ +enum EquipmentSlot { weapon, shield, armor } diff --git a/lib/src/core/model/game_state.dart b/lib/src/core/model/game_state.dart new file mode 100644 index 0000000..545682a --- /dev/null +++ b/lib/src/core/model/game_state.dart @@ -0,0 +1,389 @@ +import 'dart:collection'; +import 'package:askiineverdie/src/core/util/deterministic_random.dart'; + +/// Minimal skeletal state to mirror Progress Quest structures. +/// +/// Logic will be ported faithfully from the Delphi source; this file only +/// defines containers and helpers for deterministic RNG. +class GameState { + GameState({ + required DeterministicRandom rng, + Traits? traits, + Stats? stats, + Inventory? inventory, + Equipment? equipment, + SpellBook? spellBook, + ProgressState? progress, + QueueState? queue, + }) : rng = DeterministicRandom.clone(rng), + traits = traits ?? Traits.empty(), + stats = stats ?? Stats.empty(), + inventory = inventory ?? Inventory.empty(), + equipment = equipment ?? Equipment.empty(), + spellBook = spellBook ?? SpellBook.empty(), + progress = progress ?? ProgressState.empty(), + queue = queue ?? QueueState.empty(); + + factory GameState.withSeed({ + required int seed, + Traits? traits, + Stats? stats, + Inventory? inventory, + Equipment? equipment, + SpellBook? spellBook, + ProgressState? progress, + QueueState? queue, + }) { + return GameState( + rng: DeterministicRandom(seed), + traits: traits, + stats: stats, + inventory: inventory, + equipment: equipment, + spellBook: spellBook, + progress: progress, + queue: queue, + ); + } + + final DeterministicRandom rng; + final Traits traits; + final Stats stats; + final Inventory inventory; + final Equipment equipment; + final SpellBook spellBook; + final ProgressState progress; + final QueueState queue; + + GameState copyWith({ + DeterministicRandom? rng, + Traits? traits, + Stats? stats, + Inventory? inventory, + Equipment? equipment, + SpellBook? spellBook, + ProgressState? progress, + QueueState? queue, + }) { + return GameState( + rng: rng ?? DeterministicRandom.clone(this.rng), + traits: traits ?? this.traits, + stats: stats ?? this.stats, + inventory: inventory ?? this.inventory, + equipment: equipment ?? this.equipment, + spellBook: spellBook ?? this.spellBook, + progress: progress ?? this.progress, + queue: queue ?? this.queue, + ); + } +} + +/// 태스크 타입 (원본 fTask.Caption 값들에 대응) +enum TaskType { + neutral, // heading 등 일반 이동 + kill, // 몬스터 처치 + load, // 로딩/초기화 + plot, // 플롯 진행 + market, // 시장으로 이동 중 + sell, // 아이템 판매 중 + buying, // 장비 구매 중 +} + +class TaskInfo { + const TaskInfo({required this.caption, required this.type}); + + final String caption; + final TaskType type; + + factory TaskInfo.empty() => + const TaskInfo(caption: '', type: TaskType.neutral); + + TaskInfo copyWith({String? caption, TaskType? type}) { + return TaskInfo(caption: caption ?? this.caption, type: type ?? this.type); + } +} + +class Traits { + const Traits({ + required this.name, + required this.race, + required this.klass, + required this.level, + required this.motto, + required this.guild, + }); + + final String name; + final String race; + final String klass; + final int level; + final String motto; + final String guild; + + factory Traits.empty() => const Traits( + name: '', + race: '', + klass: '', + level: 1, + motto: '', + guild: '', + ); + + Traits copyWith({ + String? name, + String? race, + String? klass, + int? level, + String? motto, + String? guild, + }) { + return Traits( + name: name ?? this.name, + race: race ?? this.race, + klass: klass ?? this.klass, + level: level ?? this.level, + motto: motto ?? this.motto, + guild: guild ?? this.guild, + ); + } +} + +class Stats { + const Stats({ + required this.str, + required this.con, + required this.dex, + required this.intelligence, + required this.wis, + required this.cha, + required this.hpMax, + required this.mpMax, + }); + + final int str; + final int con; + final int dex; + final int intelligence; + final int wis; + final int cha; + final int hpMax; + final int mpMax; + + factory Stats.empty() => const Stats( + str: 0, + con: 0, + dex: 0, + intelligence: 0, + wis: 0, + cha: 0, + hpMax: 0, + mpMax: 0, + ); + + Stats copyWith({ + int? str, + int? con, + int? dex, + int? intelligence, + int? wis, + int? cha, + int? hpMax, + int? mpMax, + }) { + return Stats( + str: str ?? this.str, + con: con ?? this.con, + dex: dex ?? this.dex, + intelligence: intelligence ?? this.intelligence, + wis: wis ?? this.wis, + cha: cha ?? this.cha, + hpMax: hpMax ?? this.hpMax, + mpMax: mpMax ?? this.mpMax, + ); + } +} + +class InventoryEntry { + const InventoryEntry({required this.name, required this.count}); + + final String name; + final int count; + + InventoryEntry copyWith({String? name, int? count}) { + return InventoryEntry(name: name ?? this.name, count: count ?? this.count); + } +} + +class Inventory { + const Inventory({required this.gold, required this.items}); + + final int gold; + final List items; + + factory Inventory.empty() => const Inventory(gold: 0, items: []); + + Inventory copyWith({int? gold, List? items}) { + return Inventory(gold: gold ?? this.gold, items: items ?? this.items); + } +} + +class Equipment { + const Equipment({ + required this.weapon, + required this.shield, + required this.armor, + required this.bestIndex, + }); + + final String weapon; + final String shield; + final String armor; + + /// Tracks best slot index (mirror of Equips.Tag in original code; 0=weapon,1=shield,2=armor). + final int bestIndex; + + factory Equipment.empty() => const Equipment( + weapon: 'Sharp Stick', + shield: '', + armor: '', + bestIndex: 0, + ); + + Equipment copyWith({ + String? weapon, + String? shield, + String? armor, + int? bestIndex, + }) { + return Equipment( + weapon: weapon ?? this.weapon, + shield: shield ?? this.shield, + armor: armor ?? this.armor, + bestIndex: bestIndex ?? this.bestIndex, + ); + } +} + +class SpellEntry { + const SpellEntry({required this.name, required this.rank}); + + final String name; + final String rank; // e.g., Roman numerals + + SpellEntry copyWith({String? name, String? rank}) { + return SpellEntry(name: name ?? this.name, rank: rank ?? this.rank); + } +} + +class SpellBook { + const SpellBook({required this.spells}); + + final List spells; + + factory SpellBook.empty() => const SpellBook(spells: []); + + SpellBook copyWith({List? spells}) { + return SpellBook(spells: spells ?? this.spells); + } +} + +class ProgressBarState { + const ProgressBarState({required this.position, required this.max}); + + final int position; + final int max; + + factory ProgressBarState.empty() => + const ProgressBarState(position: 0, max: 1); + + ProgressBarState copyWith({int? position, int? max}) { + return ProgressBarState( + position: position ?? this.position, + max: max ?? this.max, + ); + } +} + +class ProgressState { + const ProgressState({ + required this.task, + required this.quest, + required this.plot, + required this.exp, + required this.encumbrance, + required this.currentTask, + required this.plotStageCount, + required this.questCount, + }); + + final ProgressBarState task; + final ProgressBarState quest; + final ProgressBarState plot; + final ProgressBarState exp; + final ProgressBarState encumbrance; + final TaskInfo currentTask; + final int plotStageCount; + final int questCount; + + factory ProgressState.empty() => ProgressState( + task: ProgressBarState.empty(), + quest: ProgressBarState.empty(), + plot: ProgressBarState.empty(), + exp: ProgressBarState.empty(), + encumbrance: ProgressBarState.empty(), + currentTask: TaskInfo.empty(), + plotStageCount: 1, // Prologue + questCount: 0, + ); + + ProgressState copyWith({ + ProgressBarState? task, + ProgressBarState? quest, + ProgressBarState? plot, + ProgressBarState? exp, + ProgressBarState? encumbrance, + TaskInfo? currentTask, + int? plotStageCount, + int? questCount, + }) { + return ProgressState( + task: task ?? this.task, + quest: quest ?? this.quest, + plot: plot ?? this.plot, + exp: exp ?? this.exp, + encumbrance: encumbrance ?? this.encumbrance, + currentTask: currentTask ?? this.currentTask, + plotStageCount: plotStageCount ?? this.plotStageCount, + questCount: questCount ?? this.questCount, + ); + } +} + +class QueueEntry { + const QueueEntry({ + required this.kind, + required this.durationMillis, + required this.caption, + this.taskType = TaskType.neutral, + }); + + final QueueKind kind; + final int durationMillis; + final String caption; + final TaskType taskType; +} + +enum QueueKind { task, plot } + +class QueueState { + QueueState({Iterable? entries}) + : entries = Queue.from(entries ?? const []); + + final Queue entries; + + factory QueueState.empty() => QueueState(entries: const []); + + QueueState copyWith({Iterable? entries}) { + return QueueState(entries: Queue.from(entries ?? this.entries)); + } +} diff --git a/lib/src/core/model/pq_config.dart b/lib/src/core/model/pq_config.dart new file mode 100644 index 0000000..3f4f94b --- /dev/null +++ b/lib/src/core/model/pq_config.dart @@ -0,0 +1,31 @@ +import 'package:askiineverdie/data/pq_config_data.dart'; + +/// Typed accessors for Progress Quest static data extracted from Config.dfm. +class PqConfig { + const PqConfig(); + + List get spells => _copy('Spells'); + List get offenseAttrib => _copy('OffenseAttrib'); + List get defenseAttrib => _copy('DefenseAttrib'); + List get offenseBad => _copy('OffenseBad'); + List get defenseBad => _copy('DefenseBad'); + List get shields => _copy('Shields'); + List get armors => _copy('Armors'); + List get weapons => _copy('Weapons'); + List get specials => _copy('Specials'); + List get itemAttrib => _copy('ItemAttrib'); + List get itemOfs => _copy('ItemOfs'); + List get boringItems => _copy('BoringItems'); + List get monsters => _copy('Monsters'); + List get monMods => _copy('MonMods'); + List get races => _copy('Races'); + List get klasses => _copy('Klasses'); + List get titles => _copy('Titles'); + List get impressiveTitles => _copy('ImpressiveTitles'); + + List _copy(String key) { + final values = pqConfigData[key]; + if (values == null) return const []; + return List.from(values); + } +} diff --git a/lib/src/core/model/save_data.dart b/lib/src/core/model/save_data.dart new file mode 100644 index 0000000..ec7e152 --- /dev/null +++ b/lib/src/core/model/save_data.dart @@ -0,0 +1,237 @@ +import 'dart:collection'; + +import 'package:askiineverdie/src/core/util/deterministic_random.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; + +const int kSaveVersion = 2; + +class GameSave { + GameSave({ + required this.version, + required this.rngState, + required this.traits, + required this.stats, + required this.inventory, + required this.equipment, + required this.spellBook, + required this.progress, + required this.queue, + }); + + factory GameSave.fromState(GameState state) { + return GameSave( + version: kSaveVersion, + rngState: state.rng.state, + traits: state.traits, + stats: state.stats, + inventory: state.inventory, + equipment: state.equipment, + spellBook: state.spellBook, + progress: state.progress, + queue: state.queue, + ); + } + + final int version; + final int rngState; + final Traits traits; + final Stats stats; + final Inventory inventory; + final Equipment equipment; + final SpellBook spellBook; + final ProgressState progress; + final QueueState queue; + + Map toJson() { + return { + 'version': version, + 'rng': rngState, + 'traits': { + 'name': traits.name, + 'race': traits.race, + 'klass': traits.klass, + 'level': traits.level, + 'motto': traits.motto, + 'guild': traits.guild, + }, + 'stats': { + 'str': stats.str, + 'con': stats.con, + 'dex': stats.dex, + 'int': stats.intelligence, + 'wis': stats.wis, + 'cha': stats.cha, + 'hpMax': stats.hpMax, + 'mpMax': stats.mpMax, + }, + 'inventory': { + 'gold': inventory.gold, + 'items': inventory.items + .map((e) => {'name': e.name, 'count': e.count}) + .toList(), + }, + 'equipment': { + 'weapon': equipment.weapon, + 'shield': equipment.shield, + 'armor': equipment.armor, + 'bestIndex': equipment.bestIndex, + }, + 'spells': spellBook.spells + .map((e) => {'name': e.name, 'rank': e.rank}) + .toList(), + 'progress': { + 'task': _barToJson(progress.task), + 'quest': _barToJson(progress.quest), + 'plot': _barToJson(progress.plot), + 'exp': _barToJson(progress.exp), + 'encumbrance': _barToJson(progress.encumbrance), + 'taskInfo': { + 'caption': progress.currentTask.caption, + 'type': progress.currentTask.type.name, + }, + 'plotStages': progress.plotStageCount, + 'questCount': progress.questCount, + }, + 'queue': queue.entries + .map( + (e) => { + 'kind': e.kind.name, + 'duration': e.durationMillis, + 'caption': e.caption, + 'taskType': e.taskType.name, + }, + ) + .toList(), + }; + } + + static GameSave fromJson(Map json) { + final traitsJson = json['traits'] as Map; + final statsJson = json['stats'] as Map; + final inventoryJson = json['inventory'] as Map; + final equipmentJson = json['equipment'] as Map; + final progressJson = json['progress'] as Map; + final queueJson = (json['queue'] as List? ?? []).cast(); + final spellsJson = (json['spells'] as List? ?? []).cast(); + + return GameSave( + version: json['version'] as int? ?? kSaveVersion, + rngState: json['rng'] as int? ?? 0, + traits: Traits( + name: traitsJson['name'] as String? ?? '', + race: traitsJson['race'] as String? ?? '', + klass: traitsJson['klass'] as String? ?? '', + level: traitsJson['level'] as int? ?? 1, + motto: traitsJson['motto'] as String? ?? '', + guild: traitsJson['guild'] as String? ?? '', + ), + stats: Stats( + str: statsJson['str'] as int? ?? 0, + con: statsJson['con'] as int? ?? 0, + dex: statsJson['dex'] as int? ?? 0, + intelligence: statsJson['int'] as int? ?? 0, + wis: statsJson['wis'] as int? ?? 0, + cha: statsJson['cha'] as int? ?? 0, + hpMax: statsJson['hpMax'] as int? ?? 0, + mpMax: statsJson['mpMax'] as int? ?? 0, + ), + inventory: Inventory( + gold: inventoryJson['gold'] as int? ?? 0, + items: (inventoryJson['items'] as List? ?? []) + .map( + (e) => InventoryEntry( + name: (e as Map)['name'] as String? ?? '', + count: (e)['count'] as int? ?? 0, + ), + ) + .toList(), + ), + equipment: Equipment( + weapon: equipmentJson['weapon'] as String? ?? 'Sharp Stick', + shield: equipmentJson['shield'] as String? ?? '', + armor: equipmentJson['armor'] as String? ?? '', + bestIndex: equipmentJson['bestIndex'] as int? ?? 0, + ), + spellBook: SpellBook( + spells: spellsJson + .map( + (e) => SpellEntry( + name: (e as Map)['name'] as String? ?? '', + rank: (e)['rank'] as String? ?? 'I', + ), + ) + .toList(), + ), + progress: ProgressState( + task: _barFromJson(progressJson['task'] as Map? ?? {}), + quest: _barFromJson( + progressJson['quest'] as Map? ?? {}, + ), + plot: _barFromJson(progressJson['plot'] as Map? ?? {}), + exp: _barFromJson(progressJson['exp'] as Map? ?? {}), + encumbrance: _barFromJson( + progressJson['encumbrance'] as Map? ?? {}, + ), + currentTask: _taskInfoFromJson( + progressJson['taskInfo'] as Map? ?? + {}, + ), + plotStageCount: progressJson['plotStages'] as int? ?? 1, + questCount: progressJson['questCount'] as int? ?? 0, + ), + queue: QueueState( + entries: Queue.from( + queueJson.map((e) { + final m = e as Map; + final kind = QueueKind.values.firstWhere( + (k) => k.name == m['kind'], + orElse: () => QueueKind.task, + ); + final taskType = TaskType.values.firstWhere( + (t) => t.name == m['taskType'], + orElse: () => TaskType.neutral, + ); + return QueueEntry( + kind: kind, + durationMillis: m['duration'] as int? ?? 0, + caption: m['caption'] as String? ?? '', + taskType: taskType, + ); + }), + ), + ), + ); + } + + GameState toState() { + return GameState( + rng: DeterministicRandom.fromState(rngState), + traits: traits, + stats: stats, + inventory: inventory, + equipment: equipment, + spellBook: spellBook, + progress: progress, + queue: queue, + ); + } +} + +Map _barToJson(ProgressBarState bar) => { + 'pos': bar.position, + 'max': bar.max, +}; + +ProgressBarState _barFromJson(Map json) => ProgressBarState( + position: json['pos'] as int? ?? 0, + max: json['max'] as int? ?? 1, +); + +TaskInfo _taskInfoFromJson(Map json) { + final typeName = json['type'] as String?; + final type = TaskType.values.firstWhere( + (t) => t.name == typeName, + orElse: () => TaskType.neutral, + ); + return TaskInfo(caption: json['caption'] as String? ?? '', type: type); +} diff --git a/lib/src/core/storage/save_manager.dart b/lib/src/core/storage/save_manager.dart new file mode 100644 index 0000000..612f55c --- /dev/null +++ b/lib/src/core/storage/save_manager.dart @@ -0,0 +1,33 @@ +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/save_data.dart'; +import 'package:askiineverdie/src/core/storage/save_repository.dart'; +import 'package:askiineverdie/src/core/storage/save_service.dart' + show SaveFileInfo; + +/// Coordinates saving/loading GameState using SaveRepository. +class SaveManager { + SaveManager(this._repo); + + final SaveRepository _repo; + static const String defaultFileName = 'progress.pqf'; + + /// Save current game state to disk. [fileName] may be absolute or relative. + /// Returns outcome with error on failure. + Future saveState(GameState state, {String? fileName}) { + final save = GameSave.fromState(state); + return _repo.save(save, fileName ?? defaultFileName); + } + + /// Load game state from disk. [fileName] may be absolute (e.g., file picker). + /// Returns outcome + optional state. + Future<(SaveOutcome, GameState?)> loadState({String? fileName}) async { + final (outcome, save) = await _repo.load(fileName ?? defaultFileName); + if (!outcome.success || save == null) { + return (outcome, null); + } + return (outcome, save.toState()); + } + + /// 저장 파일 목록 조회 + Future> listSaves() => _repo.listSaves(); +} diff --git a/lib/src/core/storage/save_repository.dart b/lib/src/core/storage/save_repository.dart new file mode 100644 index 0000000..726b123 --- /dev/null +++ b/lib/src/core/storage/save_repository.dart @@ -0,0 +1,64 @@ +import 'dart:io'; + +import 'package:askiineverdie/src/core/model/save_data.dart'; +import 'package:askiineverdie/src/core/storage/save_service.dart'; +import 'package:path_provider/path_provider.dart'; + +class SaveOutcome { + const SaveOutcome.success([this.error]) : success = true; + const SaveOutcome.failure(this.error) : success = false; + + final bool success; + final String? error; +} + +/// High-level save/load wrapper that resolves platform storage paths. +class SaveRepository { + SaveRepository() : _service = null; + + SaveService? _service; + + Future _ensureService() async { + if (_service != null) return; + final dir = await getApplicationSupportDirectory(); + _service = SaveService(baseDir: dir); + } + + Future save(GameSave save, String fileName) async { + try { + await _ensureService(); + await _service!.save(save, fileName); + return const SaveOutcome.success(); + } on FileSystemException catch (e) { + final reason = e.osError?.message ?? e.message; + return SaveOutcome.failure('Unable to save file: $reason'); + } catch (e) { + return SaveOutcome.failure(e.toString()); + } + } + + Future<(SaveOutcome, GameSave?)> load(String fileName) async { + try { + await _ensureService(); + final data = await _service!.load(fileName); + return (const SaveOutcome.success(), data); + } on FileSystemException catch (e) { + final reason = e.osError?.message ?? e.message; + return (SaveOutcome.failure('Unable to load save: $reason'), null); + } on FormatException catch (e) { + return (SaveOutcome.failure('Corrupted save file: ${e.message}'), null); + } catch (e) { + return (SaveOutcome.failure(e.toString()), null); + } + } + + /// 저장 파일 목록 조회 + Future> listSaves() async { + try { + await _ensureService(); + return await _service!.listSaves(); + } catch (e) { + return []; + } + } +} diff --git a/lib/src/core/storage/save_service.dart b/lib/src/core/storage/save_service.dart new file mode 100644 index 0000000..ffcef13 --- /dev/null +++ b/lib/src/core/storage/save_service.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:askiineverdie/src/core/model/save_data.dart'; + +/// Persists GameSave as JSON compressed with GZipCodec. +class SaveService { + SaveService({required this.baseDir}); + + final Directory baseDir; + final GZipCodec _gzip = GZipCodec(); + + Future save(GameSave save, String fileName) async { + final path = _resolvePath(fileName); + final file = File(path); + await file.parent.create(recursive: true); + final jsonStr = jsonEncode(save.toJson()); + final bytes = utf8.encode(jsonStr); + final compressed = _gzip.encode(bytes); + return file.writeAsBytes(compressed); + } + + Future load(String fileName) async { + final path = _resolvePath(fileName); + final file = File(path); + final compressed = await file.readAsBytes(); + final decompressed = _gzip.decode(compressed); + final jsonStr = utf8.decode(decompressed); + final map = jsonDecode(jsonStr) as Map; + return GameSave.fromJson(map); + } + + String _resolvePath(String fileName) { + final normalized = fileName.endsWith('.pqf') ? fileName : '$fileName.pqf'; + final file = File(normalized); + if (file.isAbsolute) return file.path; + return '${baseDir.path}/$normalized'; + } + + /// 저장 디렉토리의 모든 .pqf 파일 목록 반환 + Future> listSaves() async { + if (!await baseDir.exists()) { + return []; + } + + final files = []; + await for (final entity in baseDir.list()) { + if (entity is File && entity.path.endsWith('.pqf')) { + final stat = await entity.stat(); + final name = entity.uri.pathSegments.last; + files.add( + SaveFileInfo( + fileName: name, + fullPath: entity.path, + modifiedAt: stat.modified, + sizeBytes: stat.size, + ), + ); + } + } + + // 최근 수정된 파일 순으로 정렬 + files.sort((a, b) => b.modifiedAt.compareTo(a.modifiedAt)); + return files; + } +} + +/// 저장 파일 정보 +class SaveFileInfo { + const SaveFileInfo({ + required this.fileName, + required this.fullPath, + required this.modifiedAt, + required this.sizeBytes, + }); + + final String fileName; + final String fullPath; + final DateTime modifiedAt; + final int sizeBytes; + + /// 확장자 없는 표시용 이름 + String get displayName => fileName.replaceAll('.pqf', ''); +} diff --git a/lib/src/core/util/deterministic_random.dart b/lib/src/core/util/deterministic_random.dart new file mode 100644 index 0000000..e734a02 --- /dev/null +++ b/lib/src/core/util/deterministic_random.dart @@ -0,0 +1,38 @@ +/// Simple deterministic RNG (xorshift32) with serializable state. +class DeterministicRandom { + DeterministicRandom(int seed) : _state = seed & _mask; + + DeterministicRandom.clone(DeterministicRandom other) + : _state = other._state & _mask; + + DeterministicRandom.fromState(int state) : _state = state & _mask; + + static const int _mask = 0xFFFFFFFF; + + int _state; + + int get state => _state; + + /// Returns next unsigned 32-bit value. + int nextUint32() { + var x = _state; + x ^= (x << 13) & _mask; + x ^= (x >> 17) & _mask; + x ^= (x << 5) & _mask; + _state = x & _mask; + return _state; + } + + int nextInt(int maxExclusive) { + if (maxExclusive <= 0) { + throw ArgumentError.value(maxExclusive, 'maxExclusive', 'must be > 0'); + } + return nextUint32() % maxExclusive; + } + + double nextDouble() { + // 2^32 as double. + const double denom = 4294967296.0; + return nextUint32() / denom; + } +} diff --git a/lib/src/core/util/pq_logic.dart b/lib/src/core/util/pq_logic.dart new file mode 100644 index 0000000..c3375c5 --- /dev/null +++ b/lib/src/core/util/pq_logic.dart @@ -0,0 +1,820 @@ +import 'dart:collection'; +import 'dart:math' as math; + +import 'package:askiineverdie/src/core/util/deterministic_random.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/util/roman.dart'; +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; + +// Mirrors core utility functions from the original Delphi sources (Main.pas / NewGuy.pas). + +int levelUpTimeSeconds(int level) { + // ~20 minutes for level 1, then exponential growth (same as LevelUpTime in Main.pas). + final seconds = (20.0 + math.pow(1.15, level)) * 60.0; + return seconds.round(); +} + +/// 초 단위 시간을 사람이 읽기 쉬운 형태로 변환 (원본 Main.pas:1265-1271) +String roughTime(int seconds) { + if (seconds < 120) { + return '$seconds seconds'; + } else if (seconds < 60 * 120) { + return '${seconds ~/ 60} minutes'; + } else if (seconds < 60 * 60 * 48) { + return '${seconds ~/ 3600} hours'; + } else { + return '${seconds ~/ (3600 * 24)} days'; + } +} + +String pluralize(String s) { + if (_ends(s, 'y')) return '${s.substring(0, s.length - 1)}ies'; + if (_ends(s, 'us')) return '${s.substring(0, s.length - 2)}i'; + if (_ends(s, 'ch') || _ends(s, 'x') || _ends(s, 's')) return '${s}es'; + if (_ends(s, 'f')) return '${s.substring(0, s.length - 1)}ves'; + if (_ends(s, 'man') || _ends(s, 'Man')) { + return '${s.substring(0, s.length - 2)}en'; + } + return '${s}s'; +} + +String indefinite(String s, int qty) { + if (qty == 1) { + const vowels = 'AEIOUÜaeiouü'; + final first = s.isNotEmpty ? s[0] : 'a'; + final article = vowels.contains(first) ? 'an' : 'a'; + return '$article $s'; + } + return '$qty ${pluralize(s)}'; +} + +String definite(String s, int qty) { + if (qty > 1) { + s = pluralize(s); + } + return 'the $s'; +} + +String generateName(DeterministicRandom rng) { + const kParts = [ + 'br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z', + 'a|a|e|e|i|i|o|o|u|u|ae|ie|oo|ou', + 'b|ck|d|g|k|m|n|p|t|v|x|z', + ]; + + var result = ''; + for (var i = 0; i <= 5; i++) { + result += _pick(kParts[i % 3], rng); + } + if (result.isEmpty) return result; + return '${result[0].toUpperCase()}${result.substring(1)}'; +} + +// Random helpers +int randomLow(DeterministicRandom rng, int below) { + return math.min(rng.nextInt(below), rng.nextInt(below)); +} + +String pick(List values, DeterministicRandom rng) { + if (values.isEmpty) return ''; + return values[rng.nextInt(values.length)]; +} + +String pickLow(List values, DeterministicRandom rng) { + if (values.isEmpty) return ''; + return values[randomLow(rng, values.length)]; +} + +// Item name generators (match Main.pas) +String boringItem(PqConfig config, DeterministicRandom rng) { + return pick(config.boringItems, rng); +} + +String interestingItem(PqConfig config, DeterministicRandom rng) { + final attr = pick(config.itemAttrib, rng); + final special = pick(config.specials, rng); + return '$attr $special'; +} + +String specialItem(PqConfig config, DeterministicRandom rng) { + return '${interestingItem(config, rng)} of ${pick(config.itemOfs, rng)}'; +} + +String pickWeapon(PqConfig config, DeterministicRandom rng, int level) { + return _lPick(config.weapons, rng, level); +} + +String pickShield(PqConfig config, DeterministicRandom rng, int level) { + return _lPick(config.shields, rng, level); +} + +String pickArmor(PqConfig config, DeterministicRandom rng, int level) { + return _lPick(config.armors, rng, level); +} + +String pickSpell(PqConfig config, DeterministicRandom rng, int goalLevel) { + return _lPick(config.spells, rng, goalLevel); +} + +/// 원본 Main.pas:776-789 LPick: 6회 시도하여 목표 레벨에 가장 가까운 아이템 선택 +String _lPick(List items, DeterministicRandom rng, int goal) { + if (items.isEmpty) return ''; + var result = pick(items, rng); + var bestLevel = _parseLevel(result); + + for (var i = 0; i < 5; i++) { + final candidate = pick(items, rng); + final candLevel = _parseLevel(candidate); + if ((goal - candLevel).abs() < (goal - bestLevel).abs()) { + result = candidate; + bestLevel = candLevel; + } + } + return result; +} + +int _parseLevel(String entry) { + final parts = entry.split('|'); + if (parts.length < 2) return 0; + return int.tryParse(parts[1].replaceAll('+', '')) ?? 0; +} + + +String addModifier( + DeterministicRandom rng, + String baseName, + List modifiers, + int plus, +) { + var name = baseName; + var remaining = plus; + var count = 0; + + while (count < 2 && remaining != 0) { + final modifier = pick(modifiers, rng); + final parts = modifier.split('|'); + if (parts.isEmpty) break; + final label = parts[0]; + final qual = parts.length > 1 ? int.tryParse(parts[1]) ?? 0 : 0; + if (name.contains(label)) break; // avoid repeats + if (remaining.abs() < qual.abs()) break; + name = '$label $name'; + remaining -= qual; + count++; + } + + if (remaining != 0) { + name = '${remaining > 0 ? '+' : ''}$remaining $name'; + } + return name; +} + +// Character/stat growth +int levelUpTime(int level) => levelUpTimeSeconds(level); + +String winSpell( + PqConfig config, + DeterministicRandom rng, + int wisdom, + int level, +) { + // 원본 Main.pas:770-774: RandomLow로 인덱스 선택 (리스트 앞쪽 선호) + final maxIndex = math.min(wisdom + level, config.spells.length); + if (maxIndex <= 0) return ''; + final index = randomLow(rng, maxIndex); + final entry = config.spells[index]; + final parts = entry.split('|'); + final name = parts[0]; + final currentRank = romanToInt(parts.length > 1 ? parts[1] : 'I'); + final nextRank = math.max(1, currentRank + 1); + return '$name|${intToRoman(nextRank)}'; +} + +String winItem(PqConfig config, DeterministicRandom rng, int inventoryCount) { + // If inventory is already very large, signal caller to duplicate an existing item. + final threshold = math.max(250, rng.nextInt(999)); + if (inventoryCount > threshold) return ''; + return specialItem(config, rng); +} + +int rollStat(DeterministicRandom rng) { + // 3d6 roll. + return 3 + rng.nextInt(6) + rng.nextInt(6) + rng.nextInt(6); +} + +int random64Below(DeterministicRandom rng, int below) { + if (below <= 0) return 0; + final hi = rng.nextUint32(); + final lo = rng.nextUint32(); + final combined = (hi << 32) | lo; + return (combined % below).toInt(); +} + +String winEquip( + PqConfig config, + DeterministicRandom rng, + int level, + EquipmentSlot slot, +) { + // Decide item set and modifiers based on slot. + final bool isWeapon = slot == EquipmentSlot.weapon; + final items = switch (slot) { + EquipmentSlot.weapon => config.weapons, + EquipmentSlot.shield => config.shields, + EquipmentSlot.armor => config.armors, + }; + final better = isWeapon ? config.offenseAttrib : config.defenseAttrib; + final worse = isWeapon ? config.offenseBad : config.defenseBad; + + final base = _lPick(items, rng, level); + final parts = base.split('|'); + final baseName = parts[0]; + final qual = parts.length > 1 + ? int.tryParse(parts[1].replaceAll('+', '')) ?? 0 + : 0; + + final plus = level - qual; + final modifiers = plus >= 0 ? better : worse; + return addModifier(rng, baseName, modifiers, plus); +} + +int winStatIndex(DeterministicRandom rng, List statValues) { + // 원본 Main.pas:870-883: 50% 확률로 완전 랜덤, 50% 확률로 제곱 가중치 + if (rng.nextInt(2) == 0) { + // Odds(1,2): 완전 랜덤 선택 + return rng.nextInt(statValues.length); + } + // 제곱 가중치로 높은 스탯 선호 + final total = statValues.fold(0, (sum, v) => sum + v * v); + if (total == 0) return rng.nextInt(statValues.length); + var pickValue = random64Below(rng, total); + for (var i = 0; i < statValues.length; i++) { + pickValue -= statValues[i] * statValues[i]; + if (pickValue < 0) return i; + } + return statValues.length - 1; +} + +Stats winStat(Stats stats, DeterministicRandom rng) { + final values = [ + stats.str, + stats.con, + stats.dex, + stats.intelligence, + stats.wis, + stats.cha, + stats.hpMax, + stats.mpMax, + ]; + final idx = winStatIndex(rng, values); + switch (idx) { + case 0: + return stats.copyWith(str: stats.str + 1); + case 1: + return stats.copyWith(con: stats.con + 1); + case 2: + return stats.copyWith(dex: stats.dex + 1); + case 3: + return stats.copyWith(intelligence: stats.intelligence + 1); + case 4: + return stats.copyWith(wis: stats.wis + 1); + case 5: + return stats.copyWith(cha: stats.cha + 1); + case 6: + return stats.copyWith(hpMax: stats.hpMax + 1); + case 7: + return stats.copyWith(mpMax: stats.mpMax + 1); + default: + return stats; + } +} + +String monsterTask( + PqConfig config, + DeterministicRandom rng, + int level, + String? questMonster, // optional monster name from quest + int? questLevel, +) { + var targetLevel = level; + + for (var i = level; i > 0; i--) { + if (rng.nextInt(5) < 2) { + targetLevel += rng.nextInt(2) * 2 - 1; // RandSign + } + } + if (targetLevel < 1) targetLevel = 1; + + String monster; + int monsterLevel; + bool definite = false; + + // 원본 Main.pas:537-547: 가끔 NPC를 몬스터로 사용 + if (rng.nextInt(25) == 0) { + final race = pick(config.races, rng).split('|').first; + if (rng.nextInt(2) == 0) { + // 'passing Race Class' 형태 + final klass = pick(config.klasses, rng).split('|').first; + monster = 'passing $race $klass'; + } else { + // 'Title Name the Race' 형태 (원본은 PickLow(Titles) 사용) + final title = pickLow(config.titles, rng); + monster = '$title ${generateName(rng)} the $race'; + definite = true; + } + monsterLevel = targetLevel; + monster = '$monster|$monsterLevel|*'; + } else if (questMonster != null && rng.nextInt(4) == 0) { + // Use quest monster. + monster = questMonster; + monsterLevel = questLevel ?? targetLevel; + } else { + // Pick closest level among random samples. + monster = pick(config.monsters, rng); + monsterLevel = _monsterLevel(monster); + for (var i = 0; i < 5; i++) { + final candidate = pick(config.monsters, rng); + final candLevel = _monsterLevel(candidate); + if ((targetLevel - candLevel).abs() < + (targetLevel - monsterLevel).abs()) { + monster = candidate; + monsterLevel = candLevel; + } + } + } + + // Adjust quantity and adjectives based on level delta. + var qty = 1; + final levelDiff = targetLevel - monsterLevel; + var name = monster.split('|').first; + + if (levelDiff > 10) { + qty = + (targetLevel + rng.nextInt(monsterLevel == 0 ? 1 : monsterLevel)) ~/ + (monsterLevel == 0 ? 1 : monsterLevel); + if (qty < 1) qty = 1; + targetLevel ~/= qty; + } + + if (levelDiff <= -10) { + name = 'imaginary $name'; + } else if (levelDiff < -5) { + final i = 5 - rng.nextInt(10 + levelDiff + 1); + name = _sick(i, _young((monsterLevel - targetLevel) - i, name)); + } else if (levelDiff < 0) { + if (rng.nextInt(2) == 1) { + name = _sick(levelDiff, name); + } else { + name = _young(levelDiff, name); + } + } else if (levelDiff >= 10) { + name = 'messianic $name'; + } else if (levelDiff > 5) { + final i = 5 - rng.nextInt(10 - levelDiff + 1); + name = _big(i, _special((levelDiff) - i, name)); + } else if (levelDiff > 0) { + if (rng.nextInt(2) == 1) { + name = _big(levelDiff, name); + } else { + name = _special(levelDiff, name); + } + } + + if (!definite) { + name = indefinite(name, qty); + } + + return name; +} + +enum RewardKind { spell, equip, stat, item } + +class QuestResult { + const QuestResult({ + required this.caption, + required this.reward, + this.monsterName, + this.monsterLevel, + }); + + final String caption; + final RewardKind reward; + final String? monsterName; + final int? monsterLevel; +} + +QuestResult completeQuest(PqConfig config, DeterministicRandom rng, int level) { + final rewardRoll = rng.nextInt(4); + final reward = switch (rewardRoll) { + 0 => RewardKind.spell, + 1 => RewardKind.equip, + 2 => RewardKind.stat, + _ => RewardKind.item, + }; + + final questRoll = rng.nextInt(5); + switch (questRoll) { + case 0: + var best = ''; + var bestLevel = 0; + for (var i = 0; i < 4; i++) { + final m = pick(config.monsters, rng); + final l = _monsterLevel(m); + if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) { + best = m; + bestLevel = l; + } + } + final name = best.split('|').first; + return QuestResult( + caption: 'Exterminate ${definite(name, 2)}', + reward: reward, + monsterName: best, + monsterLevel: bestLevel, + ); + case 1: + final item = interestingItem(config, rng); + return QuestResult(caption: 'Seek ${definite(item, 1)}', reward: reward); + case 2: + final item = boringItem(config, rng); + return QuestResult(caption: 'Deliver this $item', reward: reward); + case 3: + final item = boringItem(config, rng); + return QuestResult( + caption: 'Fetch me ${indefinite(item, 1)}', + reward: reward, + ); + default: + var best = ''; + var bestLevel = 0; + for (var i = 0; i < 2; i++) { + final m = pick(config.monsters, rng); + final l = _monsterLevel(m); + if (i == 0 || (l - level).abs() < (bestLevel - level).abs()) { + best = m; + bestLevel = l; + } + } + final name = best.split('|').first; + return QuestResult( + caption: 'Placate ${definite(name, 2)}', + reward: reward, + monsterName: best, + monsterLevel: bestLevel, + ); + } +} + +class ActResult { + const ActResult({ + required this.actTitle, + required this.plotBarMaxSeconds, + required this.rewards, + }); + + final String actTitle; + final int plotBarMaxSeconds; + final List rewards; +} + +ActResult completeAct(int existingActCount) { + final nextActIndex = existingActCount; + final title = 'Act ${intToRoman(nextActIndex)}'; + final plotBarMax = 60 * 60 * (1 + 5 * existingActCount); + + final rewards = []; + if (existingActCount > 1) { + rewards.add(RewardKind.item); + } + if (existingActCount > 2) { + rewards.add(RewardKind.equip); + } + + return ActResult( + actTitle: title, + plotBarMaxSeconds: plotBarMax, + rewards: rewards, + ); +} + +class TaskResult { + const TaskResult({ + required this.caption, + required this.durationMillis, + required this.progress, + }); + + final String caption; + final int durationMillis; + final ProgressState progress; +} + +/// Starts a task: resets task bar and sets caption. +TaskResult startTask( + ProgressState progress, + String caption, + int durationMillis, +) { + final updated = progress.copyWith( + task: ProgressBarState(position: 0, max: durationMillis), + ); + return TaskResult( + caption: '$caption...', + durationMillis: durationMillis, + progress: updated, + ); +} + +class DequeueResult { + const DequeueResult({ + required this.progress, + required this.queue, + required this.caption, + required this.taskType, + required this.kind, + }); + + final ProgressState progress; + final QueueState queue; + final String caption; + final TaskType taskType; + final QueueKind kind; +} + +/// Process the queue when current task is done. Returns null if nothing to do. +DequeueResult? dequeue(ProgressState progress, QueueState queue) { + // Only act when the task bar is finished. + if (progress.task.position < progress.task.max) return null; + if (queue.entries.isEmpty) return null; + + final entries = Queue.from(queue.entries); + if (entries.isEmpty) return null; + + final next = entries.removeFirst(); + final taskResult = startTask(progress, next.caption, next.durationMillis); + return DequeueResult( + progress: taskResult.progress, + queue: QueueState(entries: entries.toList()), + caption: taskResult.caption, + taskType: next.taskType, + kind: next.kind, + ); +} + +int _monsterLevel(String entry) { + final parts = entry.split('|'); + if (parts.length < 2) return 0; + return int.tryParse(parts[1]) ?? 0; +} + +String _sick(int m, String s) { + switch (m) { + case -5: + case 5: + return 'dead $s'; + case -4: + case 4: + return 'comatose $s'; + case -3: + case 3: + return 'crippled $s'; + case -2: + case 2: + return 'sick $s'; + case -1: + case 1: + return 'undernourished $s'; + default: + return '$m$s'; + } +} + +String _young(int m, String s) { + switch (-m) { + case -5: + case 5: + return 'foetal $s'; + case -4: + case 4: + return 'baby $s'; + case -3: + case 3: + return 'preadolescent $s'; + case -2: + case 2: + return 'teenage $s'; + case -1: + case 1: + return 'underage $s'; + default: + return '$m$s'; + } +} + +String _big(int m, String s) { + switch (m) { + case 1: + case -1: + return 'greater $s'; + case 2: + case -2: + return 'massive $s'; + case 3: + case -3: + return 'enormous $s'; + case 4: + case -4: + return 'giant $s'; + case 5: + case -5: + return 'titanic $s'; + default: + return s; + } +} + +String _special(int m, String s) { + switch (-m) { + case 1: + case -1: + return s.contains(' ') ? 'veteran $s' : 'Battle-$s'; + case 2: + case -2: + return 'cursed $s'; + case 3: + case -3: + return s.contains(' ') ? 'warrior $s' : 'Were-$s'; + case 4: + case -4: + return 'undead $s'; + case 5: + case -5: + return 'demon $s'; + default: + return s; + } +} + +bool _ends(String s, String suffix) { + return s.length >= suffix.length && + s.substring(s.length - suffix.length) == suffix; +} + +String _pick(String pipeSeparated, DeterministicRandom rng) { + final parts = pipeSeparated.split('|'); + if (parts.isEmpty) return ''; + final idx = rng.nextInt(parts.length); + return parts[idx]; +} + +// ============================================================================= +// InterplotCinematic 관련 함수들 (Main.pas:456-521) +// ============================================================================= + +/// NPC 이름 생성 (ImpressiveGuy, Main.pas:514-521) +/// 인상적인 타이틀 + 종족 또는 이름 조합 +String impressiveGuy(PqConfig config, DeterministicRandom rng) { + var result = pick(config.impressiveTitles, rng); + switch (rng.nextInt(2)) { + case 0: + // "the King of the Elves" 형태 + final race = pick(config.races, rng).split('|').first; + result = 'the $result of the ${pluralize(race)}'; + break; + case 1: + // "King Vrognak of Zoxzik" 형태 + result = '$result ${generateName(rng)} of ${generateName(rng)}'; + break; + } + return result; +} + +/// 이름 있는 몬스터 생성 (NamedMonster, Main.pas:498-512) +/// 레벨에 맞는 몬스터 선택 후 GenerateName으로 이름 붙이기 +String namedMonster(PqConfig config, DeterministicRandom rng, int level) { + String best = ''; + int bestLevel = 0; + + // 5번 시도해서 레벨에 가장 가까운 몬스터 선택 + for (var i = 0; i < 5; i++) { + final m = pick(config.monsters, rng); + final parts = m.split('|'); + final name = parts.first; + final lev = parts.length > 1 ? (int.tryParse(parts[1]) ?? 0) : 0; + + if (best.isEmpty || (level - lev).abs() < (level - bestLevel).abs()) { + best = name; + bestLevel = lev; + } + } + + return '${generateName(rng)} the $best'; +} + +/// 플롯 간 시네마틱 이벤트 생성 (InterplotCinematic, Main.pas:456-495) +/// 3가지 시나리오 중 하나를 랜덤 선택 +List interplotCinematic( + PqConfig config, + DeterministicRandom rng, + int level, + int plotCount, +) { + final entries = []; + + // 헬퍼: 큐 엔트리 추가 (원본의 Q 함수 역할) + void q(QueueKind kind, int seconds, String caption) { + entries.add( + QueueEntry(kind: kind, durationMillis: seconds * 1000, caption: caption), + ); + } + + switch (rng.nextInt(3)) { + case 0: + // 시나리오 1: 우호적 오아시스 + q( + QueueKind.task, + 1, + 'Exhausted, you arrive at a friendly oasis in a hostile land', + ); + q(QueueKind.task, 2, 'You greet old friends and meet new allies'); + q(QueueKind.task, 2, 'You are privy to a council of powerful do-gooders'); + q(QueueKind.task, 1, 'There is much to be done. You are chosen!'); + break; + + case 1: + // 시나리오 2: 강력한 적과의 전투 + q( + QueueKind.task, + 1, + 'Your quarry is in sight, but a mighty enemy bars your path!', + ); + final nemesis = namedMonster(config, rng, level + 3); + q(QueueKind.task, 4, 'A desperate struggle commences with $nemesis'); + + var s = rng.nextInt(3); + final combatRounds = rng.nextInt(1 + plotCount); + for (var i = 0; i < combatRounds; i++) { + s += 1 + rng.nextInt(2); + switch (s % 3) { + case 0: + q(QueueKind.task, 2, 'Locked in grim combat with $nemesis'); + break; + case 1: + q(QueueKind.task, 2, '$nemesis seems to have the upper hand'); + break; + case 2: + q( + QueueKind.task, + 2, + 'You seem to gain the advantage over $nemesis', + ); + break; + } + } + + q( + QueueKind.task, + 3, + 'Victory! $nemesis is slain! Exhausted, you lose conciousness', + ); + q( + QueueKind.task, + 2, + 'You awake in a friendly place, but the road awaits', + ); + break; + + case 2: + // 시나리오 3: 배신 발견 + final guy = impressiveGuy(config, rng); + q( + QueueKind.task, + 2, + "Oh sweet relief! You've reached the kind protection of $guy", + ); + q( + QueueKind.task, + 3, + 'There is rejoicing, and an unnerving encouter with $guy in private', + ); + q( + QueueKind.task, + 2, + 'You forget your ${boringItem(config, rng)} and go back to get it', + ); + q(QueueKind.task, 2, "What's this!? You overhear something shocking!"); + q(QueueKind.task, 2, 'Could $guy be a dirty double-dealer?'); + q( + QueueKind.task, + 3, + 'Who can possibly be trusted with this news!? -- Oh yes, of course', + ); + break; + } + + // 마지막에 plot|2|Loading 추가 + q(QueueKind.plot, 2, 'Loading'); + + return entries; +} diff --git a/lib/src/core/util/roman.dart b/lib/src/core/util/roman.dart new file mode 100644 index 0000000..c40a7ee --- /dev/null +++ b/lib/src/core/util/roman.dart @@ -0,0 +1,83 @@ +const _romanMap = { + 'T': 10000, + 'A': 5000, + 'P': 100000, + 'E': 100000, // not used but kept for completeness + 'M': 1000, + 'D': 500, + 'C': 100, + 'L': 50, + 'X': 10, + 'V': 5, + 'I': 1, +}; + +String intToRoman(int n) { + final buffer = StringBuffer(); + void emit(int value, String numeral) { + while (n >= value) { + buffer.write(numeral); + n -= value; + } + } + + emit(10000, 'T'); + if (n >= 9000) { + buffer.write('MT'); + n -= 9000; + } + if (n >= 5000) { + buffer.write('A'); + n -= 5000; + } + if (n >= 4000) { + buffer.write('MA'); + n -= 4000; + } + + emit(1000, 'M'); + _subtract(ref: n, target: 900, numeral: 'CM', buffer: buffer); + _subtract(ref: n, target: 500, numeral: 'D', buffer: buffer); + _subtract(ref: n, target: 400, numeral: 'CD', buffer: buffer); + + emit(100, 'C'); + _subtract(ref: n, target: 90, numeral: 'XC', buffer: buffer); + _subtract(ref: n, target: 50, numeral: 'L', buffer: buffer); + _subtract(ref: n, target: 40, numeral: 'XL', buffer: buffer); + + emit(10, 'X'); + _subtract(ref: n, target: 9, numeral: 'IX', buffer: buffer); + _subtract(ref: n, target: 5, numeral: 'V', buffer: buffer); + _subtract(ref: n, target: 4, numeral: 'IV', buffer: buffer); + emit(1, 'I'); + return buffer.toString(); +} + +void _subtract({ + required int ref, + required int target, + required String numeral, + required StringBuffer buffer, +}) { + if (ref >= target) { + buffer.write(numeral); + ref -= target; + } +} + +int romanToInt(String n) { + var result = 0; + var i = 0; + while (i < n.length) { + final one = _romanMap[n[i]] ?? 0; + final two = i + 1 < n.length ? _romanMap[n[i + 1]] ?? 0 : 0; + if (two > one) { + result += (two - one); + i += 2; + } else { + result += one; + i += 1; + } + } + return result; +} diff --git a/lib/src/features/front/front_screen.dart b/lib/src/features/front/front_screen.dart new file mode 100644 index 0000000..84babfd --- /dev/null +++ b/lib/src/features/front/front_screen.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; + +class FrontScreen extends StatelessWidget { + const FrontScreen({super.key, this.onNewCharacter, this.onLoadSave}); + + /// "New character" 버튼 클릭 시 호출 + final void Function(BuildContext context)? onNewCharacter; + + /// "Load save" 버튼 클릭 시 호출 + final Future Function(BuildContext context)? onLoadSave; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Scaffold( + body: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [colorScheme.surfaceContainerHighest, colorScheme.surface], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 960), + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _HeroHeader(theme: theme, colorScheme: colorScheme), + const SizedBox(height: 24), + _ActionRow( + onNewCharacter: onNewCharacter != null + ? () => onNewCharacter!(context) + : () => _showPlaceholder(context), + onLoadSave: onLoadSave != null + ? () => onLoadSave!(context) + : () => _showPlaceholder(context), + ), + const SizedBox(height: 24), + const _StatusCards(), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +void _showPlaceholder(BuildContext context) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Core gameplay loop is coming next. See doc/progress-quest-flutter-plan.md for milestones.', + ), + ), + ); +} + +class _HeroHeader extends StatelessWidget { + const _HeroHeader({required this.theme, required this.colorScheme}); + + final ThemeData theme; + final ColorScheme colorScheme; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + gradient: LinearGradient( + colors: [ + colorScheme.primary.withValues(alpha: 0.9), + colorScheme.primaryContainer, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: colorScheme.primary.withValues(alpha: 0.18), + blurRadius: 18, + offset: const Offset(0, 10), + ), + ], + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.auto_awesome, color: colorScheme.onPrimary), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ascii Never Die', + style: theme.textTheme.headlineSmall?.copyWith( + color: colorScheme.onPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + 'Offline Progress Quest (PQ 6.4) rebuilt with Flutter.', + style: theme.textTheme.titleMedium?.copyWith( + color: colorScheme.onPrimary.withValues(alpha: 0.9), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 14), + Wrap( + spacing: 8, + runSpacing: 8, + children: const [ + _Tag(icon: Icons.cloud_off_outlined, label: 'No network'), + _Tag(icon: Icons.timer_outlined, label: 'Idle RPG loop'), + _Tag(icon: Icons.storage_rounded, label: 'Local saves'), + ], + ), + ], + ), + ), + ); + } +} + +class _ActionRow extends StatelessWidget { + const _ActionRow({required this.onNewCharacter, required this.onLoadSave}); + + final VoidCallback onNewCharacter; + final VoidCallback onLoadSave; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + FilledButton.icon( + onPressed: onNewCharacter, + icon: const Icon(Icons.casino_outlined), + label: const Text('New character'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + textStyle: theme.textTheme.titleMedium, + ), + ), + OutlinedButton.icon( + onPressed: onLoadSave, + icon: const Icon(Icons.folder_open), + label: const Text('Load save'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), + textStyle: theme.textTheme.titleMedium, + ), + ), + TextButton.icon( + onPressed: () => _showPlaceholder(context), + icon: const Icon(Icons.menu_book_outlined), + label: const Text('View build plan'), + ), + ], + ); + } +} + +class _StatusCards extends StatelessWidget { + const _StatusCards(); + + @override + Widget build(BuildContext context) { + return Column( + children: const [ + _InfoCard( + icon: Icons.route_outlined, + title: 'Build roadmap', + points: [ + 'Port PQ 6.4 data set (Config.dfm) into Dart constants.', + 'Recreate quest/task loop with deterministic RNG + saves.', + 'Deliver offline-first storage (GZip JSON) across platforms.', + ], + ), + SizedBox(height: 16), + _InfoCard( + icon: Icons.auto_fix_high_outlined, + title: 'Tech stack', + points: [ + 'Flutter (Material 3) with multiplatform targets enabled.', + 'path_provider + shared_preferences for local storage hooks.', + 'Strict lints with package imports enforced from day one.', + ], + ), + SizedBox(height: 16), + _InfoCard( + icon: Icons.checklist_rtl, + title: 'Today’s focus', + points: [ + 'Set up scaffold + lints.', + 'Wire seed theme and initial navigation shell.', + 'Keep reference assets under example/pq for parity.', + ], + ), + ], + ); + } +} + +class _InfoCard extends StatelessWidget { + const _InfoCard({required this.title, required this.points, this.icon}); + + final String title; + final List points; + final IconData? icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Card( + elevation: 3, + shadowColor: colorScheme.shadow.withValues(alpha: 0.2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (icon != null) ...[ + Icon(icon, color: colorScheme.primary), + const SizedBox(width: 10), + ], + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 10), + ...points.map( + (point) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 3), + child: Icon(Icons.check_circle_outline, size: 18), + ), + const SizedBox(width: 10), + Expanded( + child: Text(point, style: theme.textTheme.bodyMedium), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +class _Tag extends StatelessWidget { + const _Tag({required this.icon, required this.label}); + + final IconData icon; + final String label; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Chip( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + backgroundColor: colorScheme.onPrimary.withValues(alpha: 0.14), + avatar: Icon(icon, color: colorScheme.onPrimary, size: 16), + label: Text( + label, + style: TextStyle( + color: colorScheme.onPrimary, + fontWeight: FontWeight.w600, + ), + ), + side: BorderSide.none, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ); + } +} diff --git a/lib/src/features/front/save_picker_dialog.dart b/lib/src/features/front/save_picker_dialog.dart new file mode 100644 index 0000000..e2eacbf --- /dev/null +++ b/lib/src/features/front/save_picker_dialog.dart @@ -0,0 +1,107 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import 'package:askiineverdie/src/core/storage/save_service.dart' + show SaveFileInfo; + +/// 저장 파일 선택 다이얼로그 +/// 선택된 파일명을 반환하거나, 취소 시 null 반환 +class SavePickerDialog extends StatelessWidget { + const SavePickerDialog({super.key, required this.saves}); + + final List saves; + + /// 다이얼로그 표시 및 결과 반환 + static Future show( + BuildContext context, + List saves, + ) async { + if (saves.isEmpty) { + // 저장 파일이 없으면 안내 메시지 + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('저장된 게임이 없습니다.'))); + return null; + } + + return showDialog( + context: context, + builder: (context) => SavePickerDialog(saves: saves), + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return AlertDialog( + title: Row( + children: [ + Icon(Icons.folder_open, color: colorScheme.primary), + const SizedBox(width: 12), + const Text('Load Game'), + ], + ), + content: SizedBox( + width: 400, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 400), + child: ListView.separated( + shrinkWrap: true, + itemCount: saves.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, index) { + final save = saves[index]; + return _SaveListTile( + save: save, + onTap: () => Navigator.of(context).pop(save.fileName), + ); + }, + ), + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(null), + child: const Text('Cancel'), + ), + ], + ); + } +} + +class _SaveListTile extends StatelessWidget { + const _SaveListTile({required this.save, required this.onTap}); + + final SaveFileInfo save; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dateFormat = DateFormat('yyyy-MM-dd HH:mm'); + + return ListTile( + leading: const Icon(Icons.save), + title: Text( + save.displayName, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + '${dateFormat.format(save.modifiedAt)} · ${_formatSize(save.sizeBytes)}', + style: theme.textTheme.bodySmall, + ), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ); + } + + String _formatSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } +} diff --git a/lib/src/features/game/game_play_screen.dart b/lib/src/features/game/game_play_screen.dart new file mode 100644 index 0000000..f6aa208 --- /dev/null +++ b/lib/src/features/game/game_play_screen.dart @@ -0,0 +1,654 @@ +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; +import 'package:askiineverdie/src/features/game/game_session_controller.dart'; + +/// 게임 진행 화면 (Main.dfm 기반 3패널 레이아웃) +class GamePlayScreen extends StatefulWidget { + const GamePlayScreen({super.key, required this.controller}); + + final GameSessionController controller; + + @override + State createState() => _GamePlayScreenState(); +} + +class _GamePlayScreenState extends State + with WidgetsBindingObserver { + @override + void initState() { + super.initState(); + widget.controller.addListener(_onControllerChanged); + WidgetsBinding.instance.addObserver(this); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + widget.controller.removeListener(_onControllerChanged); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + // 앱이 백그라운드로 가거나 비활성화될 때 자동 저장 + if (state == AppLifecycleState.paused || + state == AppLifecycleState.inactive || + state == AppLifecycleState.detached) { + _saveGameState(); + } + } + + Future _saveGameState() async { + final currentState = widget.controller.state; + if (currentState == null || !widget.controller.isRunning) return; + + await widget.controller.saveManager.saveState(currentState); + } + + /// 뒤로가기 시 저장 확인 다이얼로그 + Future _onPopInvoked() async { + final shouldPop = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Exit Game'), + content: const Text('Save your progress before leaving?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(true); + }, + child: const Text('Exit without saving'), + ), + FilledButton( + onPressed: () async { + await _saveGameState(); + if (context.mounted) { + Navigator.of(context).pop(true); + } + }, + child: const Text('Save and Exit'), + ), + ], + ), + ); + return shouldPop ?? false; + } + + void _onControllerChanged() { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final state = widget.controller.state; + if (state == null) { + return const Scaffold(body: Center(child: CircularProgressIndicator())); + } + + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final shouldPop = await _onPopInvoked(); + if (shouldPop && context.mounted) { + await widget.controller.pause(saveOnStop: false); + if (context.mounted) { + Navigator.of(context).pop(); + } + } + }, + child: Scaffold( + appBar: AppBar( + title: Text('Progress Quest - ${state.traits.name}'), + actions: [ + // 치트 버튼 (디버그용) + if (widget.controller.cheatsEnabled) ...[ + IconButton( + icon: const Text('L+1'), + tooltip: 'Level Up', + onPressed: () => widget.controller.loop?.cheatCompleteTask(), + ), + IconButton( + icon: const Text('Q!'), + tooltip: 'Complete Quest', + onPressed: () => widget.controller.loop?.cheatCompleteQuest(), + ), + IconButton( + icon: const Text('P!'), + tooltip: 'Complete Plot', + onPressed: () => widget.controller.loop?.cheatCompletePlot(), + ), + ], + ], + ), + body: Column( + children: [ + // 메인 3패널 영역 + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 좌측 패널: Character Sheet + Expanded(flex: 2, child: _buildCharacterPanel(state)), + + // 중앙 패널: Equipment/Inventory + Expanded(flex: 3, child: _buildEquipmentPanel(state)), + + // 우측 패널: Plot/Quest + Expanded(flex: 2, child: _buildQuestPanel(state)), + ], + ), + ), + + // 하단: Task Progress + _buildBottomPanel(state), + ], + ), + ), + ); + } + + /// 좌측 패널: Character Sheet (Traits, Stats, Experience, Spells) + Widget _buildCharacterPanel(GameState state) { + return Card( + margin: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPanelHeader('Character Sheet'), + + // Traits 목록 + _buildSectionHeader('Traits'), + _buildTraitsList(state), + + // Stats 목록 + _buildSectionHeader('Stats'), + Expanded(flex: 2, child: _buildStatsList(state)), + + // Experience 바 + _buildSectionHeader('Experience'), + _buildProgressBar( + state.progress.exp.position, + state.progress.exp.max, + Colors.blue, + tooltip: + '${state.progress.exp.max - state.progress.exp.position} ' + 'XP needed for next level', + ), + + // Spell Book + _buildSectionHeader('Spell Book'), + Expanded(flex: 2, child: _buildSpellsList(state)), + ], + ), + ); + } + + /// 중앙 패널: Equipment/Inventory + Widget _buildEquipmentPanel(GameState state) { + return Card( + margin: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPanelHeader('Equipment'), + + // Equipment 목록 + Expanded(flex: 2, child: _buildEquipmentList(state)), + + // Inventory + _buildPanelHeader('Inventory'), + Expanded(flex: 3, child: _buildInventoryList(state)), + + // Encumbrance 바 + _buildSectionHeader('Encumbrance'), + _buildProgressBar( + state.progress.encumbrance.position, + state.progress.encumbrance.max, + Colors.orange, + ), + ], + ), + ); + } + + /// 우측 패널: Plot/Quest + Widget _buildQuestPanel(GameState state) { + return Card( + margin: const EdgeInsets.all(4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildPanelHeader('Plot Development'), + + // Plot 목록 + Expanded(child: _buildPlotList(state)), + + // Plot 바 + _buildProgressBar( + state.progress.plot.position, + state.progress.plot.max, + Colors.purple, + tooltip: state.progress.plot.max > 0 + ? '${pq_logic.roughTime(state.progress.plot.max - state.progress.plot.position)} remaining' + : null, + ), + + _buildPanelHeader('Quests'), + + // Quest 목록 + Expanded(child: _buildQuestList(state)), + + // Quest 바 + _buildProgressBar( + state.progress.quest.position, + state.progress.quest.max, + Colors.green, + tooltip: state.progress.quest.max > 0 + ? '${(100 * state.progress.quest.position ~/ state.progress.quest.max)}% complete' + : null, + ), + ], + ), + ); + } + + /// 하단 패널: Task Progress + Status + Widget _buildBottomPanel(GameState state) { + final speed = widget.controller.loop?.speedMultiplier ?? 1; + + return Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + border: Border(top: BorderSide(color: Theme.of(context).dividerColor)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 상태 메시지 + 배속 버튼 + Row( + children: [ + Expanded( + child: Text( + state.progress.currentTask.caption.isNotEmpty + ? state.progress.currentTask.caption + : 'Welcome to Progress Quest!', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ), + // 배속 버튼 + SizedBox( + height: 28, + child: OutlinedButton( + onPressed: () { + widget.controller.loop?.cycleSpeed(); + setState(() {}); + }, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + visualDensity: VisualDensity.compact, + ), + child: Text( + '${speed}x', + style: TextStyle( + fontWeight: speed > 1 ? FontWeight.bold : FontWeight.normal, + color: speed > 1 + ? Theme.of(context).colorScheme.primary + : null, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 4), + + // Task Progress 바 + _buildProgressBar( + state.progress.task.position, + state.progress.task.max, + Theme.of(context).colorScheme.primary, + ), + ], + ), + ); + } + + Widget _buildPanelHeader(String title) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + color: Theme.of(context).colorScheme.primaryContainer, + child: Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ); + } + + Widget _buildSectionHeader(String title) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + child: Text(title, style: Theme.of(context).textTheme.labelSmall), + ); + } + + Widget _buildProgressBar( + int position, + int max, + Color color, { + String? tooltip, + }) { + final progress = max > 0 ? (position / max).clamp(0.0, 1.0) : 0.0; + final bar = Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: LinearProgressIndicator( + value: progress, + backgroundColor: color.withValues(alpha: 0.2), + valueColor: AlwaysStoppedAnimation(color), + minHeight: 12, + ), + ); + + if (tooltip != null && tooltip.isNotEmpty) { + return Tooltip(message: tooltip, child: bar); + } + return bar; + } + + Widget _buildTraitsList(GameState state) { + final traits = [ + ('Name', state.traits.name), + ('Race', state.traits.race), + ('Class', state.traits.klass), + ('Level', '${state.traits.level}'), + ]; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Column( + children: traits.map((t) { + return Row( + children: [ + SizedBox( + width: 50, + child: Text(t.$1, style: const TextStyle(fontSize: 11)), + ), + Expanded( + child: Text( + t.$2, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + }).toList(), + ), + ); + } + + Widget _buildStatsList(GameState state) { + final stats = [ + ('STR', state.stats.str), + ('CON', state.stats.con), + ('DEX', state.stats.dex), + ('INT', state.stats.intelligence), + ('WIS', state.stats.wis), + ('CHA', state.stats.cha), + ('HP Max', state.stats.hpMax), + ('MP Max', state.stats.mpMax), + ]; + + return ListView.builder( + itemCount: stats.length, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemBuilder: (context, index) { + final stat = stats[index]; + return Row( + children: [ + SizedBox( + width: 50, + child: Text(stat.$1, style: const TextStyle(fontSize: 11)), + ), + Text( + '${stat.$2}', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + ], + ); + }, + ); + } + + Widget _buildSpellsList(GameState state) { + if (state.spellBook.spells.isEmpty) { + return const Center( + child: Text('No spells yet', style: TextStyle(fontSize: 11)), + ); + } + + return ListView.builder( + itemCount: state.spellBook.spells.length, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemBuilder: (context, index) { + final spell = state.spellBook.spells[index]; + return Row( + children: [ + Expanded( + child: Text( + spell.name, + style: const TextStyle(fontSize: 11), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + spell.rank, + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + ], + ); + }, + ); + } + + Widget _buildEquipmentList(GameState state) { + // 원본에는 11개 슬롯이 있지만, 현재 모델은 3개만 구현 + final equipment = [ + ('Weapon', state.equipment.weapon), + ('Shield', state.equipment.shield), + ('Armor', state.equipment.armor), + ]; + + return ListView.builder( + itemCount: equipment.length, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemBuilder: (context, index) { + final equip = equipment[index]; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: 60, + child: Text(equip.$1, style: const TextStyle(fontSize: 11)), + ), + Expanded( + child: Text( + equip.$2.isNotEmpty ? equip.$2 : '-', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + }, + ); + } + + Widget _buildInventoryList(GameState state) { + if (state.inventory.items.isEmpty) { + return Center( + child: Text( + 'Gold: ${state.inventory.gold}', + style: const TextStyle(fontSize: 11), + ), + ); + } + + return ListView.builder( + itemCount: state.inventory.items.length + 1, // +1 for gold + padding: const EdgeInsets.symmetric(horizontal: 8), + itemBuilder: (context, index) { + if (index == 0) { + return Row( + children: [ + const Expanded( + child: Text('Gold', style: TextStyle(fontSize: 11)), + ), + Text( + '${state.inventory.gold}', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + final item = state.inventory.items[index - 1]; + return Row( + children: [ + Expanded( + child: Text( + item.name, + style: const TextStyle(fontSize: 11), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '${item.count}', + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.bold), + ), + ], + ); + }, + ); + } + + Widget _buildPlotList(GameState state) { + // 플롯 단계를 표시 (Act I, Act II, ...) + final plotCount = state.progress.plotStageCount; + if (plotCount == 0) { + return const Center( + child: Text('Prologue', style: TextStyle(fontSize: 11)), + ); + } + + return ListView.builder( + itemCount: plotCount, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemBuilder: (context, index) { + final isCompleted = index < plotCount - 1; + return Row( + children: [ + Icon( + isCompleted ? Icons.check_box : Icons.check_box_outline_blank, + size: 14, + color: isCompleted ? Colors.green : Colors.grey, + ), + const SizedBox(width: 4), + Text( + index == 0 ? 'Prologue' : 'Act ${_toRoman(index)}', + style: TextStyle( + fontSize: 11, + decoration: isCompleted + ? TextDecoration.lineThrough + : TextDecoration.none, + ), + ), + ], + ); + }, + ); + } + + Widget _buildQuestList(GameState state) { + final questCount = state.progress.questCount; + if (questCount == 0) { + return const Center( + child: Text('No active quests', style: TextStyle(fontSize: 11)), + ); + } + + // 현재 퀘스트 캡션이 있으면 표시 + final currentTask = state.progress.currentTask; + return ListView( + padding: const EdgeInsets.symmetric(horizontal: 8), + children: [ + Row( + children: [ + const Icon(Icons.arrow_right, size: 14), + Expanded( + child: Text( + currentTask.caption.isNotEmpty + ? currentTask.caption + : 'Quest #$questCount', + style: const TextStyle(fontSize: 11), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + ); + } + + /// 로마 숫자 변환 (간단 버전) + String _toRoman(int number) { + const romanNumerals = [ + (1000, 'M'), + (900, 'CM'), + (500, 'D'), + (400, 'CD'), + (100, 'C'), + (90, 'XC'), + (50, 'L'), + (40, 'XL'), + (10, 'X'), + (9, 'IX'), + (5, 'V'), + (4, 'IV'), + (1, 'I'), + ]; + + var result = ''; + var remaining = number; + for (final (value, numeral) in romanNumerals) { + while (remaining >= value) { + result += numeral; + remaining -= value; + } + } + return result; + } +} diff --git a/lib/src/features/game/game_session_controller.dart b/lib/src/features/game/game_session_controller.dart new file mode 100644 index 0000000..a5b2980 --- /dev/null +++ b/lib/src/features/game/game_session_controller.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:askiineverdie/src/core/engine/progress_loop.dart'; +import 'package:askiineverdie/src/core/engine/progress_service.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/storage/save_manager.dart'; +import 'package:flutter/foundation.dart'; + +enum GameSessionStatus { idle, loading, running, error } + +/// Presentation-friendly wrapper that owns ProgressLoop and SaveManager. +class GameSessionController extends ChangeNotifier { + GameSessionController({ + required this.progressService, + required this.saveManager, + this.autoSaveConfig = const AutoSaveConfig(), + Duration tickInterval = const Duration(milliseconds: 50), + DateTime Function()? now, + }) : _tickInterval = tickInterval, + _now = now ?? DateTime.now; + + final ProgressService progressService; + final SaveManager saveManager; + final AutoSaveConfig autoSaveConfig; + + final Duration _tickInterval; + final DateTime Function() _now; + + ProgressLoop? _loop; + StreamSubscription? _subscription; + bool _cheatsEnabled = false; + + GameSessionStatus _status = GameSessionStatus.idle; + GameState? _state; + String? _error; + + GameSessionStatus get status => _status; + GameState? get state => _state; + String? get error => _error; + bool get isRunning => _status == GameSessionStatus.running; + bool get cheatsEnabled => _cheatsEnabled; + + /// 현재 ProgressLoop 인스턴스 (치트 기능용) + ProgressLoop? get loop => _loop; + + Future startNew( + GameState initialState, { + bool cheatsEnabled = false, + bool isNewGame = true, + }) async { + await _stopLoop(saveOnStop: false); + + // 새 게임인 경우 초기화 (프롤로그 태스크 설정) + final state = isNewGame + ? progressService.initializeNewGame(initialState) + : initialState; + + _state = state; + _error = null; + _status = GameSessionStatus.running; + _cheatsEnabled = cheatsEnabled; + + _loop = ProgressLoop( + initialState: state, + progressService: progressService, + saveManager: saveManager, + autoSaveConfig: autoSaveConfig, + tickInterval: _tickInterval, + now: _now, + cheatsEnabled: cheatsEnabled, + ); + + _subscription = _loop!.stream.listen((next) { + _state = next; + notifyListeners(); + }); + + _loop!.start(); + notifyListeners(); + } + + Future loadAndStart({ + String? fileName, + bool cheatsEnabled = false, + }) async { + _status = GameSessionStatus.loading; + _error = null; + notifyListeners(); + + final (outcome, loaded) = await saveManager.loadState(fileName: fileName); + if (!outcome.success || loaded == null) { + _status = GameSessionStatus.error; + _error = outcome.error ?? 'Unknown error'; + notifyListeners(); + return; + } + + await startNew(loaded, cheatsEnabled: cheatsEnabled, isNewGame: false); + } + + Future pause({bool saveOnStop = false}) async { + await _stopLoop(saveOnStop: saveOnStop); + _status = GameSessionStatus.idle; + notifyListeners(); + } + + @override + void dispose() { + final stop = _stopLoop(saveOnStop: false); + if (stop != null) { + unawaited(stop); + } + super.dispose(); + } + + Future? _stopLoop({required bool saveOnStop}) { + final loop = _loop; + final sub = _subscription; + _loop = null; + _subscription = null; + + sub?.cancel(); + if (loop == null) return null; + return loop.stop(saveOnStop: saveOnStop); + } +} diff --git a/lib/src/features/new_character/new_character_screen.dart b/lib/src/features/new_character/new_character_screen.dart new file mode 100644 index 0000000..0977e7e --- /dev/null +++ b/lib/src/features/new_character/new_character_screen.dart @@ -0,0 +1,484 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/util/deterministic_random.dart'; +import 'package:askiineverdie/src/core/util/pq_logic.dart'; + +/// 캐릭터 생성 화면 (NewGuy.pas 포팅) +class NewCharacterScreen extends StatefulWidget { + const NewCharacterScreen({super.key, this.onCharacterCreated}); + + /// 캐릭터 생성 완료 시 호출되는 콜백 + final void Function(GameState initialState)? onCharacterCreated; + + @override + State createState() => _NewCharacterScreenState(); +} + +class _NewCharacterScreenState extends State { + final PqConfig _config = const PqConfig(); + final TextEditingController _nameController = TextEditingController(); + + // 종족(races)과 직업(klasses) 목록 + late final List _races; + late final List _klasses; + + // 선택된 종족/직업 인덱스 + int _selectedRaceIndex = 0; + int _selectedKlassIndex = 0; + + // 능력치(stats) + int _str = 0; + int _con = 0; + int _dex = 0; + int _int = 0; + int _wis = 0; + int _cha = 0; + + // 롤 이력 (Unroll 기능용) + final List _rollHistory = []; + + // 현재 RNG 시드 (Re-Roll 전 저장) + int _currentSeed = 0; + + // 이름 생성용 RNG + late DeterministicRandom _nameRng; + + @override + void initState() { + super.initState(); + + // 종족/직업 목록 로드 (name|attribute 형식에서 name만 추출) + _races = _config.races.map((e) => e.split('|').first).toList(); + _klasses = _config.klasses.map((e) => e.split('|').first).toList(); + + // 초기 랜덤화 + final random = math.Random(); + _selectedRaceIndex = random.nextInt(_races.length); + _selectedKlassIndex = random.nextInt(_klasses.length); + + // 초기 스탯 굴림 + _currentSeed = random.nextInt(0x7FFFFFFF); + _nameRng = DeterministicRandom(random.nextInt(0x7FFFFFFF)); + _rollStats(); + + // 초기 이름 생성 + _nameController.text = generateName(_nameRng); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + /// 스탯 굴림 (3d6 × 6) + void _rollStats() { + final rng = DeterministicRandom(_currentSeed); + setState(() { + _str = rollStat(rng); + _con = rollStat(rng); + _dex = rollStat(rng); + _int = rollStat(rng); + _wis = rollStat(rng); + _cha = rollStat(rng); + }); + } + + /// Re-Roll 버튼 클릭 + void _onReroll() { + // 현재 시드를 이력에 저장 + _rollHistory.insert(0, _currentSeed); + + // 새 시드로 굴림 + _currentSeed = math.Random().nextInt(0x7FFFFFFF); + _rollStats(); + } + + /// Unroll 버튼 클릭 (이전 롤로 복원) + void _onUnroll() { + if (_rollHistory.isEmpty) return; + + setState(() { + _currentSeed = _rollHistory.removeAt(0); + }); + _rollStats(); + } + + /// 이름 생성 버튼 클릭 + void _onGenerateName() { + setState(() { + _nameController.text = generateName(_nameRng); + }); + } + + /// Total 값 계산 + int get _total => _str + _con + _dex + _int + _wis + _cha; + + /// Total 색상 결정 (원본 규칙) + /// 63+18(81) 이상 = 빨강, 4*18(72) 초과 = 노랑 + /// 63-18(45) 이하 = 회색, 3*18(54) 미만 = 은색 + /// 그 외 = 흰색 + Color _getTotalColor() { + final total = _total; + if (total >= 81) return Colors.red; + if (total > 72) return Colors.yellow; + if (total <= 45) return Colors.grey; + if (total < 54) return Colors.grey.shade400; + return Colors.white; + } + + /// Sold! 버튼 클릭 - 캐릭터 생성 완료 + void _onSold() { + final name = _nameController.text.trim(); + if (name.isEmpty) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('이름을 입력해주세요.'))); + return; + } + + // 게임에 사용할 새 RNG 생성 + final gameSeed = math.Random().nextInt(0x7FFFFFFF); + + // 종족/직업의 보너스 스탯 파싱 + final raceEntry = _config.races[_selectedRaceIndex]; + final klassEntry = _config.klasses[_selectedKlassIndex]; + final raceBonus = _parseStatBonus(raceEntry); + final klassBonus = _parseStatBonus(klassEntry); + + // 최종 스탯 계산 (기본 + 종족 보너스 + 직업 보너스) + final finalStats = Stats( + str: _str + (raceBonus['STR'] ?? 0) + (klassBonus['STR'] ?? 0), + con: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0), + dex: _dex + (raceBonus['DEX'] ?? 0) + (klassBonus['DEX'] ?? 0), + intelligence: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0), + wis: _wis + (raceBonus['WIS'] ?? 0) + (klassBonus['WIS'] ?? 0), + cha: _cha + (raceBonus['CHA'] ?? 0) + (klassBonus['CHA'] ?? 0), + hpMax: _con + (raceBonus['CON'] ?? 0) + (klassBonus['CON'] ?? 0), + mpMax: _int + (raceBonus['INT'] ?? 0) + (klassBonus['INT'] ?? 0), + ); + + final traits = Traits( + name: name, + race: _races[_selectedRaceIndex], + klass: _klasses[_selectedKlassIndex], + level: 1, + motto: '', + guild: '', + ); + + // 초기 게임 상태 생성 + final initialState = GameState.withSeed( + seed: gameSeed, + traits: traits, + stats: finalStats, + inventory: const Inventory(gold: 0, items: []), + equipment: Equipment.empty(), + spellBook: SpellBook.empty(), + progress: ProgressState.empty(), + queue: QueueState.empty(), + ); + + widget.onCharacterCreated?.call(initialState); + } + + /// 종족/직업 보너스 파싱 (예: "Half Orc|STR+2,INT-1") + Map _parseStatBonus(String entry) { + final parts = entry.split('|'); + if (parts.length < 2) return {}; + + final bonuses = {}; + final bonusPart = parts[1]; + + // STR+2,INT-1 형식 파싱 + final regex = RegExp(r'([A-Z]+)([+-]\d+)'); + for (final match in regex.allMatches(bonusPart)) { + final stat = match.group(1)!; + final value = int.parse(match.group(2)!); + bonuses[stat] = value; + } + return bonuses; + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Progress Quest - New Character'), + centerTitle: true, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // 이름 입력 섹션 + _buildNameSection(), + const SizedBox(height: 16), + + // 능력치 섹션 + _buildStatsSection(), + const SizedBox(height: 16), + + // 종족/직업 선택 섹션 + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(child: _buildRaceSection()), + const SizedBox(width: 16), + Expanded(child: _buildKlassSection()), + ], + ), + const SizedBox(height: 24), + + // Sold! 버튼 + FilledButton.icon( + onPressed: _onSold, + icon: const Icon(Icons.check), + label: const Text('Sold!'), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + ), + ), + ], + ), + ), + ); + } + + Widget _buildNameSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Name', + border: OutlineInputBorder(), + ), + maxLength: 30, + ), + ), + const SizedBox(width: 8), + IconButton.filled( + onPressed: _onGenerateName, + icon: const Icon(Icons.casino), + tooltip: 'Generate Name', + ), + ], + ), + ), + ); + } + + Widget _buildStatsSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Stats', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + + // 스탯 그리드 + Row( + children: [ + Expanded(child: _buildStatTile('STR', _str)), + Expanded(child: _buildStatTile('CON', _con)), + Expanded(child: _buildStatTile('DEX', _dex)), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded(child: _buildStatTile('INT', _int)), + Expanded(child: _buildStatTile('WIS', _wis)), + Expanded(child: _buildStatTile('CHA', _cha)), + ], + ), + const SizedBox(height: 12), + + // Total + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: _getTotalColor().withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: _getTotalColor()), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Total', + style: TextStyle(fontWeight: FontWeight.bold), + ), + Text( + '$_total', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: _getTotalColor() == Colors.white + ? Colors.black + : _getTotalColor(), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + + // Roll 버튼들 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + OutlinedButton.icon( + onPressed: _onUnroll, + icon: const Icon(Icons.undo), + label: const Text('Unroll'), + style: OutlinedButton.styleFrom( + foregroundColor: _rollHistory.isEmpty ? Colors.grey : null, + ), + ), + const SizedBox(width: 16), + FilledButton.icon( + onPressed: _onReroll, + icon: const Icon(Icons.casino), + label: const Text('Roll'), + ), + ], + ), + if (_rollHistory.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + '${_rollHistory.length} roll(s) in history', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ), + ], + ), + ), + ); + } + + Widget _buildStatTile(String label, int value) { + return Container( + margin: const EdgeInsets.all(4), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Text(label, style: Theme.of(context).textTheme.labelSmall), + const SizedBox(height: 4), + Text( + '$value', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + ), + ], + ), + ); + } + + Widget _buildRaceSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Race', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + SizedBox( + height: 300, + child: ListView.builder( + itemCount: _races.length, + itemBuilder: (context, index) { + final isSelected = index == _selectedRaceIndex; + return ListTile( + leading: Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text( + _races[index], + style: TextStyle( + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + dense: true, + visualDensity: VisualDensity.compact, + onTap: () => setState(() => _selectedRaceIndex = index), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildKlassSection() { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Class', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + SizedBox( + height: 300, + child: ListView.builder( + itemCount: _klasses.length, + itemBuilder: (context, index) { + final isSelected = index == _selectedKlassIndex; + return ListTile( + leading: Icon( + isSelected + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + color: isSelected + ? Theme.of(context).colorScheme.primary + : null, + ), + title: Text( + _klasses[index], + style: TextStyle( + fontWeight: isSelected + ? FontWeight.bold + : FontWeight.normal, + ), + ), + dense: true, + visualDensity: VisualDensity.compact, + onTap: () => setState(() => _selectedKlassIndex = index), + ); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000..d3896c9 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000..f63c2c7 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,128 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "askiineverdie") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.example.askiineverdie") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000..d5bd016 --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..e71a16d --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void fl_register_plugins(FlPluginRegistry* registry) { +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..2e1de87 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt new file mode 100644 index 0000000..e97dabc --- /dev/null +++ b/linux/runner/CMakeLists.txt @@ -0,0 +1,26 @@ +cmake_minimum_required(VERSION 3.13) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the application ID. +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") diff --git a/linux/runner/main.cc b/linux/runner/main.cc new file mode 100644 index 0000000..e7c5c54 --- /dev/null +++ b/linux/runner/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/runner/my_application.cc b/linux/runner/my_application.cc new file mode 100644 index 0000000..e909aff --- /dev/null +++ b/linux/runner/my_application.cc @@ -0,0 +1,144 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Called when first Flutter frame received. +static void first_frame_cb(MyApplication* self, FlView *view) +{ + gtk_widget_show(gtk_widget_get_toplevel(GTK_WIDGET(view))); +} + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "askiineverdie"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "askiineverdie"); + } + + gtk_window_set_default_size(window, 1280, 720); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + GdkRGBA background_color; + // Background defaults to black, override it here if necessary, e.g. #00000000 for transparent. + gdk_rgba_parse(&background_color, "#000000"); + fl_view_set_background_color(view, &background_color); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + // Show the window when Flutter renders. + // Requires the view to be realized so we can start rendering. + g_signal_connect_swapped(view, "first-frame", G_CALLBACK(first_frame_cb), self); + gtk_widget_realize(GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + //MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + // Set the program name to the application ID, which helps various systems + // like GTK and desktop environments map this running application to its + // corresponding .desktop file. This ensures better integration by allowing + // the application to be recognized beyond its binary name. + g_set_prgname(APPLICATION_ID); + + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/runner/my_application.h b/linux/runner/my_application.h new file mode 100644 index 0000000..72271d5 --- /dev/null +++ b/linux/runner/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000..746adbb --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,7 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/dgph +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000..4b81f9b --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000..5caa9d1 --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..b8e2b22 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import path_provider_foundation +import shared_preferences_foundation + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000..ff5ddb3 --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,42 @@ +platform :osx, '10.15' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + end +end diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000..8347d07 --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,705 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; +/* End PBXBuildFile section */ + +/* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* askiineverdie.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "askiineverdie.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; + 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; + 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* askiineverdie.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + ); + path = Runner; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* askiineverdie.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1100; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "echo \"$PRODUCT_NAME.app\" > \"$PROJECT_DIR\"/Flutter/ephemeral/.app_filename && \"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh embed\n"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + Flutter/ephemeral/FlutterInputs.xcfilelist, + ); + inputPaths = ( + Flutter/ephemeral/tripwire, + ); + outputFileListPaths = ( + Flutter/ephemeral/FlutterOutputs.xcfilelist, + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/askiineverdie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/askiineverdie"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/askiineverdie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/askiineverdie"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/askiineverdie.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/askiineverdie"; + }; + name = Profile; + }; + 338D0CE9231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = 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_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CODE_SIGN_IDENTITY = "-"; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; + 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_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 33E5194F232828860026EE4D /* AppInfo.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000..20f18af --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..1d526a1 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000..b3c1761 --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +@main +class AppDelegate: FlutterAppDelegate { + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..a2ec33f --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_16.png", + "scale" : "1x" + }, + { + "size" : "16x16", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "2x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_32.png", + "scale" : "1x" + }, + { + "size" : "32x32", + "idiom" : "mac", + "filename" : "app_icon_64.png", + "scale" : "2x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "2x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_256.png", + "scale" : "1x" + }, + { + "size" : "256x256", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "2x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_512.png", + "scale" : "1x" + }, + { + "size" : "512x512", + "idiom" : "mac", + "filename" : "app_icon_1024.png", + "scale" : "2x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000..82b6f9d Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png new file mode 100644 index 0000000..13b35eb Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png new file mode 100644 index 0000000..0a3f5fa Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png new file mode 100644 index 0000000..bdb5722 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png new file mode 100644 index 0000000..f083318 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png new file mode 100644 index 0000000..326c0e7 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png new file mode 100644 index 0000000..2f1632c Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000..80e867a --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000..2786252 --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = askiineverdie + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.example.askiineverdie + +// The copyright displayed in application information +PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. diff --git a/macos/Runner/Configs/Debug.xcconfig b/macos/Runner/Configs/Debug.xcconfig new file mode 100644 index 0000000..36b0fd9 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000..dff4f49 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000..42bcbf4 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000..dddb8a3 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.network.server + + + diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000..4789daa --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000..3cc05eb --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,15 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController() + let windowFrame = self.frame + self.contentViewController = flutterViewController + self.setFrame(windowFrame, display: true) + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000..852fa1a --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.app-sandbox + + + diff --git a/macos/RunnerTests/RunnerTests.swift b/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 0000000..61f3bd1 --- /dev/null +++ b/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..a2136e8 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,378 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" + source: hosted + version: "2.13.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + characters: + dependency: transitive + description: + name: characters + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" + source: hosted + version: "1.4.0" + clock: + dependency: transitive + description: + name: clock + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" + source: hosted + version: "1.1.2" + collection: + dependency: transitive + description: + name: collection + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" + source: hosted + version: "1.19.1" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + fake_async: + dependency: "direct dev" + description: + name: fake_async + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" + source: hosted + version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + file: + dependency: transitive + description: + name: file + sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 + url: "https://pub.dev" + source: hosted + version: "7.0.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" + url: "https://pub.dev" + source: hosted + version: "5.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + lints: + dependency: transitive + description: + name: lints + sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" + source: hosted + version: "0.11.1" + meta: + dependency: transitive + description: + name: meta + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" + source: hosted + version: "1.16.0" + path: + dependency: transitive + description: + name: path + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" + source: hosted + version: "1.9.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" + url: "https://pub.dev" + source: hosted + version: "2.1.5" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + url: "https://pub.dev" + source: hosted + version: "2.2.22" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + url: "https://pub.dev" + source: hosted + version: "2.5.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.dev" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "6e8bf70b7fef813df4e9a36f658ac46d107db4b4cfe1048b477d4e453a8159f5" + url: "https://pub.dev" + source: hosted + version: "2.5.3" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + url: "https://pub.dev" + source: hosted + version: "2.4.18" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + source_span: + dependency: transitive + description: + name: source_span + sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + url: "https://pub.dev" + source: hosted + version: "1.10.1" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" + source: hosted + version: "1.12.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" + source: hosted + version: "0.7.6" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" + source: hosted + version: "2.2.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" + source: hosted + version: "15.0.2" + web: + dependency: transitive + description: + name: web + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" +sdks: + dart: ">=3.9.2 <4.0.0" + flutter: ">=3.35.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..25c7c40 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,90 @@ +name: askiineverdie +description: "Offline Progress Quest rebuild in Flutter." +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.0.0+1 + +environment: + sdk: ^3.9.2 + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + cupertino_icons: ^1.0.8 + intl: ^0.19.0 + path_provider: ^2.1.4 + shared_preferences: ^2.3.1 + +dev_dependencies: + flutter_test: + sdk: flutter + fake_async: ^1.3.2 + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^5.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/ + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/to/resolution-aware-images + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/to/asset-from-package + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/to/font-from-package diff --git a/test/core/engine/progress_loop_test.dart b/test/core/engine/progress_loop_test.dart new file mode 100644 index 0000000..4bc72d0 --- /dev/null +++ b/test/core/engine/progress_loop_test.dart @@ -0,0 +1,100 @@ +import 'package:askiineverdie/src/core/engine/game_mutations.dart'; +import 'package:askiineverdie/src/core/engine/progress_loop.dart'; +import 'package:askiineverdie/src/core/engine/progress_service.dart'; +import 'package:askiineverdie/src/core/engine/reward_service.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/storage/save_manager.dart'; +import 'package:askiineverdie/src/core/storage/save_repository.dart'; +import 'package:askiineverdie/src/core/storage/save_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeSaveManager implements SaveManager { + final List savedStates = []; + + @override + Future saveState(GameState state, {String? fileName}) async { + savedStates.add(state); + return const SaveOutcome.success(); + } + + @override + Future<(SaveOutcome, GameState?)> loadState({String? fileName}) async { + return (const SaveOutcome.success(), null); + } + + @override + Future> listSaves() async => []; +} + +void main() { + late ProgressService service; + + setUp(() { + const config = PqConfig(); + final mutations = GameMutations(config); + service = ProgressService( + config: config, + mutations: mutations, + rewards: RewardService(mutations), + ); + }); + + test('autosaves on level-up and stop when configured', () async { + final saveManager = _FakeSaveManager(); + final initial = GameState.withSeed( + seed: 123, + traits: const Traits( + name: 'LoopHero', + race: 'Orc', + klass: 'Warrior', + level: 1, + motto: '', + guild: '', + ), + stats: const Stats( + str: 8, + con: 7, + dex: 6, + intelligence: 5, + wis: 4, + cha: 3, + hpMax: 9, + mpMax: 8, + ), + progress: const ProgressState( + task: ProgressBarState(position: 1200, max: 1200), + quest: ProgressBarState(position: 0, max: 10), + plot: ProgressBarState(position: 0, max: 10), + exp: ProgressBarState(position: 3, max: 3), + encumbrance: ProgressBarState(position: 0, max: 0), + currentTask: TaskInfo(caption: 'Battle', type: TaskType.kill), + plotStageCount: 1, + questCount: 0, + ), + ); + + final loop = ProgressLoop( + initialState: initial, + progressService: service, + saveManager: saveManager, + autoSaveConfig: const AutoSaveConfig( + onLevelUp: true, + onQuestComplete: true, + onActComplete: true, + onStop: true, + ), + now: () => DateTime.fromMillisecondsSinceEpoch(0), + ); + + final updated = loop.tickOnce(deltaMillis: 50); + + expect(saveManager.savedStates.length, 1); + expect(updated.traits.level, 2); + + await loop.stop(saveOnStop: true); + + expect(saveManager.savedStates.length, 2); + expect(saveManager.savedStates.last, same(updated)); + }); +} diff --git a/test/core/engine/progress_service_test.dart b/test/core/engine/progress_service_test.dart new file mode 100644 index 0000000..ae0cafe --- /dev/null +++ b/test/core/engine/progress_service_test.dart @@ -0,0 +1,158 @@ +import 'package:askiineverdie/src/core/engine/game_mutations.dart'; +import 'package:askiineverdie/src/core/engine/progress_service.dart'; +import 'package:askiineverdie/src/core/engine/reward_service.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late ProgressService service; + late PqConfig config; + + setUp(() { + config = const PqConfig(); + final mutations = GameMutations(config); + service = ProgressService( + config: config, + mutations: mutations, + rewards: RewardService(mutations), + ); + }); + + test('tick advances task bar and recalculates encumbrance', () { + final state = GameState.withSeed( + seed: 42, + stats: const Stats( + str: 5, + con: 0, + dex: 0, + intelligence: 0, + wis: 0, + cha: 0, + hpMax: 0, + mpMax: 0, + ), + inventory: const Inventory( + gold: 5, + items: [ + InventoryEntry(name: 'Rock', count: 3), + ], + ), + progress: const ProgressState( + task: ProgressBarState(position: 0, max: 80), + quest: ProgressBarState(position: 0, max: 10), + plot: ProgressBarState(position: 0, max: 10), + exp: ProgressBarState(position: 0, max: 50), + encumbrance: ProgressBarState(position: 0, max: 0), + currentTask: TaskInfo(caption: 'Test', type: TaskType.kill), + plotStageCount: 1, + questCount: 0, + ), + ); + + final result = service.tick(state, 150); + + expect(result.leveledUp, isFalse); + expect(result.completedQuest, isFalse); + expect(result.completedAct, isFalse); + expect(result.state.progress.task.position, 80); + expect(result.state.progress.task.max, 80); + expect(result.state.progress.encumbrance.position, 3); + expect(result.state.progress.encumbrance.max, 15); + expect(result.state.progress.currentTask.caption, 'Test'); + }); + + test('tick levels up when EXP is full during kill task', () { + final initial = GameState.withSeed( + seed: 7, + traits: const Traits( + name: 'Hero', + race: 'Human', + klass: 'Fighter', + level: 1, + motto: '', + guild: '', + ), + stats: const Stats( + str: 10, + con: 9, + dex: 8, + intelligence: 7, + wis: 6, + cha: 5, + hpMax: 10, + mpMax: 11, + ), + progress: const ProgressState( + task: ProgressBarState(position: 1000, max: 1000), + quest: ProgressBarState(position: 0, max: 10), + plot: ProgressBarState(position: 0, max: 10), + exp: ProgressBarState(position: 5, max: 5), + encumbrance: ProgressBarState(position: 0, max: 0), + currentTask: TaskInfo(caption: 'Battle', type: TaskType.kill), + plotStageCount: 1, + questCount: 0, + ), + ); + + final result = service.tick(initial, 50); + + expect(result.leveledUp, isTrue); + expect(result.shouldAutosave, isTrue); + expect(result.state.traits.level, 2); + expect(result.state.stats.hpMax, greaterThan(initial.stats.hpMax)); + expect(result.state.stats.mpMax, greaterThan(initial.stats.mpMax)); + expect(result.state.progress.exp.position, 0); + expect(result.state.progress.exp.max, pq_logic.levelUpTime(2)); + // 태스크 완료 후 새 태스크가 자동으로 시작됨 + expect(result.state.progress.task.position, 0); + expect(result.state.progress.task.max, greaterThan(0)); + }); + + test('quest completion enqueues next task and resets quest bar', () { + final initial = GameState.withSeed( + seed: 99, + traits: const Traits( + name: 'Questor', + race: 'Elf', + klass: 'Mage', + level: 3, + motto: '', + guild: '', + ), + stats: const Stats( + str: 4, + con: 5, + dex: 6, + intelligence: 7, + wis: 8, + cha: 9, + hpMax: 12, + mpMax: 10, + ), + progress: const ProgressState( + task: ProgressBarState(position: 2000, max: 2000), + quest: ProgressBarState(position: 4, max: 5), + plot: ProgressBarState(position: 0, max: 20), + exp: ProgressBarState(position: 0, max: 30), + encumbrance: ProgressBarState(position: 0, max: 0), + currentTask: TaskInfo(caption: 'Hunt', type: TaskType.kill), + plotStageCount: 2, + questCount: 1, + ), + queue: QueueState(entries: const []), + ); + + final result = service.tick(initial, 50); + final nextState = result.state; + + expect(result.completedQuest, isTrue); + expect(nextState.progress.questCount, 2); + expect(nextState.progress.quest.position, 0); + expect(nextState.progress.quest.max, inInclusiveRange(50, 149)); + expect(nextState.progress.currentTask.type, TaskType.neutral); + expect(nextState.progress.task.position, 0); + expect(nextState.queue.entries, isEmpty); + }); +} diff --git a/test/core/util/pq_logic_test.dart b/test/core/util/pq_logic_test.dart new file mode 100644 index 0000000..6c0db6a --- /dev/null +++ b/test/core/util/pq_logic_test.dart @@ -0,0 +1,245 @@ +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/util/deterministic_random.dart'; +import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const config = PqConfig(); + + test('levelUpTime grows with level and matches expected seconds', () { + expect(pq_logic.levelUpTime(1), 1269); + expect(pq_logic.levelUpTime(10), 1443); + }); + + test('roughTime formats seconds into human-readable strings', () { + // < 120초: seconds + expect(pq_logic.roughTime(60), '60 seconds'); + expect(pq_logic.roughTime(119), '119 seconds'); + + // 120초 ~ 2시간: minutes + expect(pq_logic.roughTime(120), '2 minutes'); + expect(pq_logic.roughTime(3600), '60 minutes'); + expect(pq_logic.roughTime(7199), '119 minutes'); + + // 2시간 ~ 48시간: hours + expect(pq_logic.roughTime(7200), '2 hours'); + expect(pq_logic.roughTime(86400), '24 hours'); + expect(pq_logic.roughTime(172799), '47 hours'); + + // 48시간 이상: days + expect(pq_logic.roughTime(172800), '2 days'); + expect(pq_logic.roughTime(604800), '7 days'); + }); + + test('generateName produces deterministic names per seed', () { + expect(pq_logic.generateName(DeterministicRandom(123)), 'Grunax'); + expect(pq_logic.generateName(DeterministicRandom(42)), 'Bregpran'); + }); + + test('indefinite/definite/pluralize honor English rules', () { + expect(pq_logic.indefinite('apple', 1), 'an apple'); + expect(pq_logic.indefinite('orc', 3), '3 orcs'); + expect(pq_logic.definite('goblin', 2), 'the goblins'); + expect(pq_logic.pluralize('baby'), 'babies'); + }); + + test('item and reward helpers are deterministic with seed', () { + expect(pq_logic.boringItem(config, DeterministicRandom(12)), 'egg'); + expect( + pq_logic.interestingItem(config, DeterministicRandom(12)), + 'Golden Ornament', + ); + expect( + pq_logic.specialItem(config, DeterministicRandom(12)), + 'Golden Ornament of Efficiency', + ); + // 원본 Main.pas:770-774 RandomLow 방식으로 수정됨 + expect( + pq_logic.winSpell(config, DeterministicRandom(22), 7, 4), + 'Slime Finger|II', + ); + expect( + pq_logic.winEquip( + config, + DeterministicRandom(12), + 5, + EquipmentSlot.weapon, + ), + 'Baselard', + ); + expect( + pq_logic.winEquip( + config, + DeterministicRandom(15), + 2, + EquipmentSlot.armor, + ), + '-2 Canvas', + ); + expect( + pq_logic.winItem(config, DeterministicRandom(10), 3), + 'Golden Hymnal of Cruelty', + ); + expect(pq_logic.winItem(config, DeterministicRandom(10), 1000), isEmpty); + }); + + test('monsterTask picks level-appropriate monsters with modifiers', () { + expect( + pq_logic.monsterTask(config, DeterministicRandom(99), 5, null, null), + 'an underage Rakshasa', + ); + expect( + pq_logic.monsterTask(config, DeterministicRandom(7), 10, null, null), + 'a greater Sphinx', + ); + expect( + pq_logic.monsterTask(config, DeterministicRandom(5), 6, 'Goblin|3', 3), + 'a Barbed Devil', + ); + }); + + test('completeQuest and completeAct return deterministic results', () { + final quest = pq_logic.completeQuest(config, DeterministicRandom(33), 4); + expect(quest.caption, 'Deliver this chicken'); + expect(quest.reward, pq_logic.RewardKind.item); + expect(quest.monsterName, isNull); + + final act2 = pq_logic.completeAct(2); + expect(act2.actTitle, 'Act II'); + expect(act2.plotBarMaxSeconds, 39600); + expect(act2.rewards, contains(pq_logic.RewardKind.item)); + expect(act2.rewards, isNot(contains(pq_logic.RewardKind.equip))); + + final act3 = pq_logic.completeAct(3); + expect( + act3.rewards, + containsAll({ + pq_logic.RewardKind.item, + pq_logic.RewardKind.equip, + }), + ); + }); + + test('dequeue starts next task and drains queue', () { + const progress = ProgressState( + task: ProgressBarState(position: 100, max: 100), + quest: ProgressBarState(position: 0, max: 10), + plot: ProgressBarState(position: 0, max: 10), + exp: ProgressBarState(position: 0, max: 10), + encumbrance: ProgressBarState(position: 0, max: 1), + currentTask: TaskInfo(caption: 'Idle', type: TaskType.neutral), + plotStageCount: 1, + questCount: 0, + ); + final queue = QueueState( + entries: const [ + QueueEntry( + kind: QueueKind.task, + durationMillis: 120, + caption: 'Fight', + taskType: TaskType.kill, + ), + QueueEntry( + kind: QueueKind.plot, + durationMillis: 80, + caption: 'Plot', + taskType: TaskType.plot, + ), + ], + ); + + final result = pq_logic.dequeue(progress, queue); + expect(result, isNotNull); + expect(result!.caption, 'Fight...'); + expect(result.taskType, TaskType.kill); + expect(result.kind, QueueKind.task); + expect(result.progress.task.position, 0); + expect(result.progress.task.max, 120); + expect(result.queue.entries.length, 1); + expect(result.queue.entries.first.kind, QueueKind.plot); + }); + + test('impressiveGuy generates deterministic NPC titles', () { + // case 0: "the King of the Elves" 형태 + final guy1 = pq_logic.impressiveGuy(config, DeterministicRandom(42)); + expect(guy1, contains('of the')); + + // case 1: "King Vrognak of Zoxzik" 형태 + final guy2 = pq_logic.impressiveGuy(config, DeterministicRandom(7)); + expect(guy2, contains(' of ')); + + // 결정적(deterministic) 결과 확인 + expect( + pq_logic.impressiveGuy(config, DeterministicRandom(100)), + pq_logic.impressiveGuy(config, DeterministicRandom(100)), + ); + }); + + test('namedMonster generates named monster with level matching', () { + final monster = pq_logic.namedMonster(config, DeterministicRandom(42), 10); + // "GeneratedName the MonsterType" 형태 + expect(monster, contains(' the ')); + + // 결정적(deterministic) 결과 확인 + expect( + pq_logic.namedMonster(config, DeterministicRandom(50), 5), + pq_logic.namedMonster(config, DeterministicRandom(50), 5), + ); + }); + + test('interplotCinematic generates queue entries with plot ending', () { + final entries = pq_logic.interplotCinematic( + config, + DeterministicRandom(42), + 10, + 3, + ); + + // 최소 1개 이상의 엔트리 생성 + expect(entries, isNotEmpty); + + // 마지막은 항상 plot 타입의 'Loading' + expect(entries.last.kind, QueueKind.plot); + expect(entries.last.caption, 'Loading'); + expect(entries.last.durationMillis, 2000); + + // 나머지는 task 타입 + for (var i = 0; i < entries.length - 1; i++) { + expect(entries[i].kind, QueueKind.task); + } + }); + + test('interplotCinematic has three distinct scenarios', () { + // 여러 시드를 테스트해서 3가지 시나리오가 모두 나오는지 확인 + final scenariosFound = {}; + + for (var seed = 0; seed < 100; seed++) { + final entries = pq_logic.interplotCinematic( + config, + DeterministicRandom(seed), + 5, + 1, + ); + + final firstCaption = entries.first.caption; + if (firstCaption.contains('oasis')) { + scenariosFound.add('oasis'); + // 오아시스 시나리오: 4개 task + 1개 plot = 5개 + expect(entries.length, 5); + } else if (firstCaption.contains('quarry')) { + scenariosFound.add('combat'); + // 전투 시나리오: 가변 길이 (combatRounds에 따라) + expect(entries.length, greaterThanOrEqualTo(5)); + } else if (firstCaption.contains('sweet relief')) { + scenariosFound.add('betrayal'); + // 배신 시나리오: 6개 task + 1개 plot = 7개 + expect(entries.length, 7); + } + } + + // 3가지 시나리오가 모두 발견되어야 함 + expect(scenariosFound, containsAll(['oasis', 'combat', 'betrayal'])); + }); +} diff --git a/test/features/game_play_screen_test.dart b/test/features/game_play_screen_test.dart new file mode 100644 index 0000000..0d4ce6f --- /dev/null +++ b/test/features/game_play_screen_test.dart @@ -0,0 +1,199 @@ +import 'package:askiineverdie/src/core/engine/game_mutations.dart'; +import 'package:askiineverdie/src/core/engine/progress_service.dart'; +import 'package:askiineverdie/src/core/engine/reward_service.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/storage/save_manager.dart'; +import 'package:askiineverdie/src/core/storage/save_repository.dart'; +import 'package:askiineverdie/src/core/storage/save_service.dart'; +import 'package:askiineverdie/src/features/game/game_play_screen.dart'; +import 'package:askiineverdie/src/features/game/game_session_controller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeSaveManager implements SaveManager { + @override + Future saveState(GameState state, {String? fileName}) async { + return const SaveOutcome.success(); + } + + @override + Future<(SaveOutcome, GameState?)> loadState({String? fileName}) async { + return (const SaveOutcome.success(), null); + } + + @override + Future> listSaves() async => []; +} + +GameState _createTestState() { + return GameState.withSeed( + seed: 42, + traits: const Traits( + name: 'TestHero', + race: 'Elf', + klass: 'Mage', + level: 5, + motto: 'Test Motto', + guild: '', + ), + stats: const Stats( + str: 10, + con: 12, + dex: 14, + intelligence: 16, + wis: 11, + cha: 9, + hpMax: 50, + mpMax: 40, + ), + progress: const ProgressState( + task: ProgressBarState(position: 500, max: 1000), + quest: ProgressBarState(position: 300, max: 600), + plot: ProgressBarState(position: 1800, max: 3600), + exp: ProgressBarState(position: 500, max: 1500), + encumbrance: ProgressBarState(position: 5, max: 20), + currentTask: TaskInfo(caption: 'Battling a Goblin', type: TaskType.kill), + plotStageCount: 2, + questCount: 3, + ), + ); +} + +GameSessionController _createController() { + const config = PqConfig(); + final mutations = GameMutations(config); + return GameSessionController( + progressService: ProgressService( + config: config, + mutations: mutations, + rewards: RewardService(mutations), + ), + saveManager: _FakeSaveManager(), + tickInterval: const Duration(seconds: 10), // 느린 틱 + ); +} + +void main() { + testWidgets('GamePlayScreen renders 3-panel layout', (tester) async { + final controller = _createController(); + addTearDown(() async { + await controller.pause(saveOnStop: false); + controller.dispose(); + }); + + await controller.startNew(_createTestState(), isNewGame: false); + + await tester.pumpWidget( + MaterialApp(home: GamePlayScreen(controller: controller)), + ); + + // AppBar 타이틀 확인 + expect(find.text('Progress Quest - TestHero'), findsOneWidget); + + // 3패널 헤더 확인 + expect(find.text('Character Sheet'), findsOneWidget); + expect(find.text('Equipment'), findsOneWidget); + expect(find.text('Plot Development'), findsOneWidget); + expect(find.text('Quests'), findsOneWidget); + + // 테스트 완료 후 정리 + await controller.pause(saveOnStop: false); + }); + + testWidgets('GamePlayScreen shows character traits', (tester) async { + final controller = _createController(); + addTearDown(() async { + await controller.pause(saveOnStop: false); + controller.dispose(); + }); + + await controller.startNew(_createTestState(), isNewGame: false); + + await tester.pumpWidget( + MaterialApp(home: GamePlayScreen(controller: controller)), + ); + + // Traits 섹션 확인 + expect(find.text('Traits'), findsOneWidget); + expect(find.text('TestHero'), findsOneWidget); + expect(find.text('Elf'), findsOneWidget); + expect(find.text('Mage'), findsOneWidget); + + await controller.pause(saveOnStop: false); + }); + + testWidgets('GamePlayScreen shows stats', (tester) async { + final controller = _createController(); + addTearDown(() async { + await controller.pause(saveOnStop: false); + controller.dispose(); + }); + + await controller.startNew(_createTestState(), isNewGame: false); + + await tester.pumpWidget( + MaterialApp(home: GamePlayScreen(controller: controller)), + ); + + // Stats 섹션 확인 + expect(find.text('Stats'), findsOneWidget); + expect(find.text('STR'), findsOneWidget); + expect(find.text('CON'), findsOneWidget); + + await controller.pause(saveOnStop: false); + }); + + testWidgets('GamePlayScreen shows current task caption', (tester) async { + final controller = _createController(); + addTearDown(() async { + await controller.pause(saveOnStop: false); + controller.dispose(); + }); + + await controller.startNew(_createTestState(), isNewGame: false); + + await tester.pumpWidget( + MaterialApp(home: GamePlayScreen(controller: controller)), + ); + + // 현재 태스크 캡션 확인 (퀘스트 목록과 하단 패널에 표시됨) + expect(find.text('Battling a Goblin'), findsAtLeast(1)); + + await controller.pause(saveOnStop: false); + }); + + testWidgets('GamePlayScreen shows progress bars', (tester) async { + final controller = _createController(); + addTearDown(() async { + await controller.pause(saveOnStop: false); + controller.dispose(); + }); + + await controller.startNew(_createTestState(), isNewGame: false); + + await tester.pumpWidget( + MaterialApp(home: GamePlayScreen(controller: controller)), + ); + + // LinearProgressIndicator가 여러 개 표시되는지 확인 + expect(find.byType(LinearProgressIndicator), findsAtLeast(4)); + + await controller.pause(saveOnStop: false); + }); + + testWidgets('Loading state shows CircularProgressIndicator', (tester) async { + final controller = _createController(); + addTearDown(() { + controller.dispose(); + }); + + // 상태 없이 시작 (startNew 호출 안 함) + await tester.pumpWidget( + MaterialApp(home: GamePlayScreen(controller: controller)), + ); + + // 로딩 인디케이터 확인 + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); +} diff --git a/test/features/game_session_controller_test.dart b/test/features/game_session_controller_test.dart new file mode 100644 index 0000000..bc34367 --- /dev/null +++ b/test/features/game_session_controller_test.dart @@ -0,0 +1,143 @@ +import 'package:askiineverdie/src/core/engine/game_mutations.dart'; +import 'package:askiineverdie/src/core/engine/progress_service.dart'; +import 'package:askiineverdie/src/core/engine/reward_service.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/storage/save_manager.dart'; +import 'package:askiineverdie/src/core/storage/save_repository.dart'; +import 'package:askiineverdie/src/core/storage/save_service.dart'; +import 'package:askiineverdie/src/features/game/game_session_controller.dart'; +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeSaveManager implements SaveManager { + final List savedStates = []; + (SaveOutcome, GameState?) Function(String?)? onLoad; + SaveOutcome saveOutcome = const SaveOutcome.success(); + + @override + Future saveState(GameState state, {String? fileName}) async { + savedStates.add(state); + return saveOutcome; + } + + @override + Future<(SaveOutcome, GameState?)> loadState({String? fileName}) async { + if (onLoad != null) { + return onLoad!(fileName); + } + return (const SaveOutcome.success(), null); + } + + @override + Future> listSaves() async => []; +} + +void main() { + const config = PqConfig(); + final mutations = GameMutations(config); + final progressService = ProgressService( + config: config, + mutations: mutations, + rewards: RewardService(mutations), + ); + + GameSessionController buildController( + FakeAsync async, + FakeSaveManager saveManager, + ) { + return GameSessionController( + progressService: progressService, + saveManager: saveManager, + tickInterval: const Duration(milliseconds: 10), + now: () => + DateTime.fromMillisecondsSinceEpoch(async.elapsed.inMilliseconds), + ); + } + + GameState sampleState() { + return GameState.withSeed( + seed: 1, + traits: const Traits( + name: 'Hero', + race: 'Human', + klass: 'Fighter', + level: 1, + motto: '', + guild: '', + ), + stats: const Stats( + str: 5, + con: 5, + dex: 5, + intelligence: 5, + wis: 5, + cha: 5, + hpMax: 10, + mpMax: 8, + ), + progress: const ProgressState( + task: ProgressBarState(position: 0, max: 50), + quest: ProgressBarState(position: 0, max: 1000), + plot: ProgressBarState(position: 0, max: 1000), + exp: ProgressBarState(position: 0, max: 9999), + encumbrance: ProgressBarState(position: 0, max: 1), + currentTask: TaskInfo(caption: 'Battle', type: TaskType.kill), + plotStageCount: 1, + questCount: 0, + ), + ); + } + + test('startNew runs loop and publishes state updates', () { + fakeAsync((async) { + final saveManager = FakeSaveManager(); + final controller = buildController(async, saveManager); + + controller.startNew(sampleState(), isNewGame: false); + async.flushMicrotasks(); + + expect(controller.status, GameSessionStatus.running); + expect(controller.state, isNotNull); + + async.elapse(const Duration(milliseconds: 30)); + async.flushMicrotasks(); + + expect(controller.state!.progress.task.position, greaterThan(0)); + + controller.pause(); + async.flushMicrotasks(); + expect(controller.status, GameSessionStatus.idle); + }); + }); + + test('loadAndStart surfaces save load errors', () { + fakeAsync((async) { + final saveManager = FakeSaveManager() + ..onLoad = (_) => (const SaveOutcome.failure('boom'), null); + final controller = buildController(async, saveManager); + + controller.loadAndStart(fileName: 'bad.pqf'); + async.flushMicrotasks(); + + expect(controller.status, GameSessionStatus.error); + expect(controller.error, 'boom'); + }); + }); + + test('pause saves on stop when requested', () { + fakeAsync((async) { + final saveManager = FakeSaveManager(); + final controller = buildController(async, saveManager); + + controller.startNew(sampleState(), isNewGame: false); + async.flushMicrotasks(); + + controller.pause(saveOnStop: true); + async.flushMicrotasks(); + + expect(controller.status, GameSessionStatus.idle); + expect(saveManager.savedStates.length, 1); + }); + }); +} diff --git a/test/features/new_character_screen_test.dart b/test/features/new_character_screen_test.dart new file mode 100644 index 0000000..3503dd8 --- /dev/null +++ b/test/features/new_character_screen_test.dart @@ -0,0 +1,107 @@ +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/features/new_character/new_character_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('NewCharacterScreen renders main sections', (tester) async { + await tester.pumpWidget( + MaterialApp(home: NewCharacterScreen(onCharacterCreated: (_) {})), + ); + + // 화면 타이틀 확인 + expect(find.text('Progress Quest - New Character'), findsOneWidget); + + // 종족 섹션 확인 + expect(find.text('Race'), findsOneWidget); + + // 직업 섹션 확인 + expect(find.text('Class'), findsOneWidget); + + // 능력치 섹션 확인 + expect(find.text('Stats'), findsOneWidget); + expect(find.text('STR'), findsOneWidget); + expect(find.text('CON'), findsOneWidget); + + // Sold! 버튼 확인 + expect(find.text('Sold!'), findsOneWidget); + }); + + testWidgets('Unroll button exists and can be tapped', (tester) async { + await tester.pumpWidget( + MaterialApp(home: NewCharacterScreen(onCharacterCreated: (_) {})), + ); + + // Unroll 버튼 확인 + final unrollButton = find.text('Unroll'); + expect(unrollButton, findsOneWidget); + + // Unroll 버튼 탭 + await tester.tap(unrollButton); + await tester.pump(); + + // Total이 표시되는지 확인 + expect(find.textContaining('Total'), findsOneWidget); + }); + + testWidgets('Sold button creates character with generated name', ( + tester, + ) async { + GameState? createdState; + + await tester.pumpWidget( + MaterialApp( + home: NewCharacterScreen( + onCharacterCreated: (state) { + createdState = state; + }, + ), + ), + ); + + // Sold! 버튼이 보이도록 스크롤 + await tester.scrollUntilVisible( + find.text('Sold!'), + 500.0, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + // Sold! 버튼 탭 + await tester.tap(find.text('Sold!')); + await tester.pumpAndSettle(); + + // 콜백이 호출되었는지 확인 + expect(createdState, isNotNull); + expect(createdState!.traits.name.isNotEmpty, isTrue); + expect(createdState!.traits.level, 1); + expect(createdState!.traits.race.isNotEmpty, isTrue); + expect(createdState!.traits.klass.isNotEmpty, isTrue); + }); + + testWidgets('Stats section displays all six stats', (tester) async { + await tester.pumpWidget( + MaterialApp(home: NewCharacterScreen(onCharacterCreated: (_) {})), + ); + + // 능력치 라벨들이 표시되는지 확인 + expect(find.text('STR'), findsOneWidget); + expect(find.text('CON'), findsOneWidget); + expect(find.text('DEX'), findsOneWidget); + expect(find.text('INT'), findsOneWidget); + expect(find.text('WIS'), findsOneWidget); + expect(find.text('CHA'), findsOneWidget); + + // Total 라벨 확인 + expect(find.textContaining('Total'), findsOneWidget); + }); + + testWidgets('Name text field exists', (tester) async { + await tester.pumpWidget( + MaterialApp(home: NewCharacterScreen(onCharacterCreated: (_) {})), + ); + + // TextField 확인 (이름 입력 필드) + expect(find.byType(TextField), findsOneWidget); + }); +} diff --git a/test/regression/deterministic_game_test.dart b/test/regression/deterministic_game_test.dart new file mode 100644 index 0000000..03d3f88 --- /dev/null +++ b/test/regression/deterministic_game_test.dart @@ -0,0 +1,358 @@ +/// 원본 대비 회귀 테스트 (Regression Test) +/// +/// 동일 시드에서 원본 Progress Quest와 동일한 결과가 나오는지 확인합니다. +/// 이 테스트는 게임 로직의 결정적(deterministic) 특성을 보장합니다. +library; + +import 'package:askiineverdie/src/core/engine/game_mutations.dart'; +import 'package:askiineverdie/src/core/engine/progress_service.dart'; +import 'package:askiineverdie/src/core/engine/reward_service.dart'; +import 'package:askiineverdie/src/core/model/equipment_slot.dart'; +import 'package:askiineverdie/src/core/model/game_state.dart'; +import 'package:askiineverdie/src/core/model/pq_config.dart'; +import 'package:askiineverdie/src/core/util/deterministic_random.dart'; +import 'package:askiineverdie/src/core/util/pq_logic.dart' as pq_logic; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const config = PqConfig(); + final mutations = GameMutations(config); + late ProgressService service; + + setUp(() { + service = ProgressService( + config: config, + mutations: mutations, + rewards: RewardService(mutations), + ); + }); + + group('Deterministic Game Flow - Seed 42', () { + // 고정 시드 42에서의 예상 결과값 (스냅샷) + const testSeed = 42; + + test('generateName produces consistent names', () { + // 시드 42에서 생성되는 이름들 검증 + expect(pq_logic.generateName(DeterministicRandom(testSeed)), 'Bregpran'); + expect(pq_logic.generateName(DeterministicRandom(100)), 'Grotlaex'); + expect(pq_logic.generateName(DeterministicRandom(999)), 'Idfrok'); + }); + + test('monsterTask produces consistent monster names', () { + // 시드 42, 레벨 5에서의 몬스터 이름 + expect( + pq_logic.monsterTask( + config, + DeterministicRandom(testSeed), + 5, + null, + null, + ), + 'an underage Su-monster', + ); + + // 시드 42, 레벨 10에서의 몬스터 이름 + expect( + pq_logic.monsterTask( + config, + DeterministicRandom(testSeed), + 10, + null, + null, + ), + 'a cursed Troll', + ); + + // 시드 42, 레벨 1에서의 몬스터 이름 + expect( + pq_logic.monsterTask( + config, + DeterministicRandom(testSeed), + 1, + null, + null, + ), + 'a greater Crayfish', + ); + }); + + test('winEquip produces consistent equipment', () { + // 시드 42에서 무기 획득 + expect( + pq_logic.winEquip( + config, + DeterministicRandom(testSeed), + 5, + EquipmentSlot.weapon, + ), + 'Longiron', + ); + + // 시드 42에서 방어구 획득 + expect( + pq_logic.winEquip( + config, + DeterministicRandom(testSeed), + 5, + EquipmentSlot.armor, + ), + '-1 Holey Mildewed Bearskin', + ); + + // 시드 42에서 방패 획득 + expect( + pq_logic.winEquip( + config, + DeterministicRandom(testSeed), + 5, + EquipmentSlot.shield, + ), + 'Round Shield', + ); + }); + + test('winSpell produces consistent spells', () { + // 원본 Main.pas:770-774 RandomLow 방식 적용 + // 시드 42에서 주문 획득 (레벨 5, 지능 10) + expect( + pq_logic.winSpell(config, DeterministicRandom(testSeed), 5, 10), + 'Aqueous Humor|II', + ); + + // 시드 100에서 주문 획득 + expect( + pq_logic.winSpell(config, DeterministicRandom(100), 10, 15), + 'Shoelaces|II', + ); + }); + + test('winItem produces consistent items', () { + // 시드 42에서 아이템 획득 + expect( + pq_logic.winItem(config, DeterministicRandom(testSeed), 5), + 'Ormolu Garnet of Nervousness', + ); + + // 시드 100에서 아이템 획득 + expect( + pq_logic.winItem(config, DeterministicRandom(100), 10), + 'Fearsome Gemstone of Fortune', + ); + }); + + test('completeQuest produces consistent rewards', () { + // 시드 42에서 퀘스트 완료 + final quest = pq_logic.completeQuest( + config, + DeterministicRandom(testSeed), + 5, + ); + expect(quest.caption, 'Fetch me a canoe'); + expect(quest.reward, pq_logic.RewardKind.spell); + + // 시드 100에서 퀘스트 완료 + final quest2 = pq_logic.completeQuest( + config, + DeterministicRandom(100), + 3, + ); + expect(quest2.caption, 'Placate the Bunnies'); + expect(quest2.reward, pq_logic.RewardKind.stat); + }); + + test('interplotCinematic produces consistent storylines', () { + // 시드 42에서 시네마틱 이벤트 + final entries = pq_logic.interplotCinematic( + config, + DeterministicRandom(testSeed), + 10, + 3, + ); + + // 첫 번째 엔트리 확인 (시나리오 타입에 따라 다름) + expect(entries.isNotEmpty, isTrue); + expect(entries.last.caption, 'Loading'); + expect(entries.last.kind, QueueKind.plot); + }); + + test('namedMonster produces consistent named monsters', () { + expect( + pq_logic.namedMonster(config, DeterministicRandom(testSeed), 10), + 'Groxiex the Otyugh', + ); + + expect( + pq_logic.namedMonster(config, DeterministicRandom(100), 5), + 'Druckmox the Koala', + ); + }); + + test('impressiveGuy produces consistent NPC titles', () { + final guy1 = pq_logic.impressiveGuy( + config, + DeterministicRandom(testSeed), + ); + // 시드 42에서의 NPC 타이틀 확인 + expect(guy1.isNotEmpty, isTrue); + expect(guy1, contains('of')); + + final guy2 = pq_logic.impressiveGuy(config, DeterministicRandom(100)); + expect(guy2.isNotEmpty, isTrue); + }); + }); + + group('Game State Progression - Deterministic', () { + test('initial game state is consistent for same seed', () { + final state1 = GameState.withSeed( + seed: 42, + traits: const Traits( + name: 'Hero', + race: 'Elf', + klass: 'Mage', + level: 1, + motto: '', + guild: '', + ), + stats: const Stats( + str: 10, + con: 10, + dex: 10, + intelligence: 10, + wis: 10, + cha: 10, + hpMax: 20, + mpMax: 15, + ), + ); + + final state2 = GameState.withSeed( + seed: 42, + traits: const Traits( + name: 'Hero', + race: 'Elf', + klass: 'Mage', + level: 1, + motto: '', + guild: '', + ), + stats: const Stats( + str: 10, + con: 10, + dex: 10, + intelligence: 10, + wis: 10, + cha: 10, + hpMax: 20, + mpMax: 15, + ), + ); + + // 동일 시드로 생성된 상태는 동일한 RNG 시퀀스를 가짐 + expect(state1.rng.nextInt(100), state2.rng.nextInt(100)); + }); + + test('tick produces consistent state changes', () { + final initialState = GameState.withSeed( + seed: 42, + traits: const Traits( + name: 'TestHero', + race: 'Human', + klass: 'Fighter', + level: 1, + motto: '', + guild: '', + ), + stats: const Stats( + str: 12, + con: 10, + dex: 8, + intelligence: 6, + wis: 7, + cha: 9, + hpMax: 10, + mpMax: 8, + ), + progress: const ProgressState( + task: ProgressBarState(position: 0, max: 1000), + quest: ProgressBarState(position: 0, max: 10000), + plot: ProgressBarState(position: 0, max: 36000), + exp: ProgressBarState(position: 0, max: 1269), + encumbrance: ProgressBarState(position: 0, max: 22), + currentTask: TaskInfo(caption: 'Battle', type: TaskType.kill), + plotStageCount: 1, + questCount: 0, + ), + ); + + // 첫 번째 틱 (100ms) + final result1 = service.tick(initialState, 100); + + // 동일한 초기 상태에서 동일한 증분으로 틱 + final result1b = service.tick(initialState, 100); + + // 태스크 진행이 동일해야 함 + expect( + result1.state.progress.task.position, + result1b.state.progress.task.position, + ); + }); + + test('levelUp stat gains are deterministic', () { + // 레벨업 시 스탯 증가 검증 + final baseStats = const Stats( + str: 10, + con: 10, + dex: 10, + intelligence: 10, + wis: 10, + cha: 10, + hpMax: 20, + mpMax: 15, + ); + + // 동일 시드에서 winStat은 동일한 결과를 반환해야 함 + final stat1 = pq_logic.winStat(baseStats, DeterministicRandom(42)); + final stat2 = pq_logic.winStat(baseStats, DeterministicRandom(42)); + + expect(stat1.str, stat2.str); + expect(stat1.con, stat2.con); + expect(stat1.dex, stat2.dex); + expect(stat1.intelligence, stat2.intelligence); + expect(stat1.wis, stat2.wis); + expect(stat1.cha, stat2.cha); + }); + }); + + group('Config Data Integrity', () { + test('races list matches original count', () { + // 원본 Config.dfm의 Races 개수: 21 + expect(config.races.length, 21); + }); + + test('klasses list matches original count', () { + // 원본 Config.dfm의 Klasses 개수: 18 + expect(config.klasses.length, 18); + }); + + test('monsters list matches original count', () { + // 원본 Config.dfm의 Monsters 개수: 231 (540-770줄) + expect(config.monsters.length, 231); + }); + + test('spells list is not empty', () { + expect(config.spells.isNotEmpty, isTrue); + }); + + test('weapons list is not empty', () { + expect(config.weapons.isNotEmpty, isTrue); + }); + + test('armors list is not empty', () { + expect(config.armors.isNotEmpty, isTrue); + }); + + test('shields list is not empty', () { + expect(config.shields.isNotEmpty, isTrue); + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..8517dcf --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,24 @@ +import 'package:askiineverdie/src/app.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Front screen renders and navigates to new character', ( + tester, + ) async { + await tester.pumpWidget(const AskiiNeverDieApp()); + + // 프런트 화면이 렌더링되었는지 확인 + expect(find.text('Ascii Never Die'), findsOneWidget); + expect(find.textContaining('Offline Progress Quest'), findsOneWidget); + + // "New character" 버튼 탭 + await tester.tap(find.text('New character')); + await tester.pumpAndSettle(); + + // NewCharacterScreen으로 이동했는지 확인 + expect(find.text('Progress Quest - New Character'), findsOneWidget); + expect(find.text('Race'), findsOneWidget); + expect(find.text('Class'), findsOneWidget); + expect(find.text('Sold!'), findsOneWidget); + }); +} diff --git a/tool/dfm_extract.dart b/tool/dfm_extract.dart new file mode 100644 index 0000000..ab91dcb --- /dev/null +++ b/tool/dfm_extract.dart @@ -0,0 +1,145 @@ +// Extracts TMemo Lines.Strings from example/pq/Config.dfm into a Dart const map. +// Usage: +// dart run tool/dfm_extract.dart [input_dfm] [output_dart] +// Defaults: +// input_dfm: example/pq/Config.dfm +// output_dart: lib/data/pq_config_data.dart + +import 'dart:convert'; +import 'dart:io'; + +void main(List args) { + final inputPath = args.isNotEmpty ? args[0] : 'example/pq/Config.dfm'; + final outputPath = args.length > 1 ? args[1] : 'lib/data/pq_config_data.dart'; + + final file = File(inputPath); + if (!file.existsSync()) { + stderr.writeln('Input file not found: $inputPath'); + exitCode = 1; + return; + } + + final lines = file.readAsLinesSync(); + final data = _parseMemos(lines); + _writeDart(outputPath, data); + _printSummary(outputPath, data); +} + +Map> _parseMemos(List lines) { + final result = >{}; + String? currentMemo; + bool inLines = false; + + for (final rawLine in lines) { + final line = rawLine.trimRight(); + final trimmed = line.trimLeft(); + + // Detect TMemo object names. + if (trimmed.startsWith('object ') && trimmed.contains(': TMemo')) { + final token = trimmed.split(' ')[1]; + currentMemo = token.contains(':') ? token.split(':').first : token; + inLines = false; + continue; + } + + // Detect start of Lines.Strings block. + if (trimmed.contains('Lines.Strings')) { + if (currentMemo == null) continue; + inLines = true; + result[currentMemo] = []; + continue; + } + + if (!inLines || currentMemo == null) { + continue; + } + + // End of block. + if (trimmed == ')') { + inLines = false; + continue; + } + + // Extract string literal line (may end with "')" on last item). + final hasClosing = trimmed.endsWith(')'); + final value = _decodeDelphiLiteral(line, stripTrailingParen: hasClosing); + if (value.isNotEmpty) { + result[currentMemo]!.add(value); + } + + // Handle closing parenthesis on same line. + if (hasClosing) { + inLines = false; + } + } + + return result; +} + +String _decodeDelphiLiteral(String line, {required bool stripTrailingParen}) { + // Remove trailing ')' only when it denotes end-of-block. + var work = line; + if (stripTrailingParen && work.trimRight().endsWith(')')) { + work = work.substring(0, work.lastIndexOf(')')); + } + + final buffer = StringBuffer(); + var i = 0; + while (i < work.length) { + final ch = work[i]; + if (ch == '\'') { + // Quoted segment. + i++; + final start = i; + while (i < work.length && work[i] != '\'') { + i++; + } + buffer.write(work.substring(start, i)); + i++; // Skip closing quote. + } else if (ch == '#') { + // Numeric char code segment. + i++; + final start = i; + while (i < work.length && _isDigit(work.codeUnitAt(i))) { + i++; + } + final code = int.parse(work.substring(start, i)); + buffer.write(String.fromCharCode(code)); + } else { + i++; // Ignore other characters between tokens. + } + } + return buffer.toString(); +} + +bool _isDigit(int charCode) => charCode >= 48 && charCode <= 57; + +void _writeDart(String outputPath, Map> data) { + final buffer = StringBuffer() + ..writeln('// GENERATED CODE - DO NOT EDIT BY HAND.') + ..writeln( + '// Generated by tool/dfm_extract.dart from example/pq/Config.dfm', + ) + ..writeln('') + ..writeln('const Map> pqConfigData = {'); + + final sortedKeys = data.keys.toList()..sort(); + for (final key in sortedKeys) { + final encodedKey = jsonEncode(key); + final encodedList = jsonEncode(data[key]); + buffer.writeln(' $encodedKey: $encodedList,'); + } + + buffer.writeln('};'); + + File(outputPath) + ..createSync(recursive: true) + ..writeAsStringSync(buffer.toString()); +} + +void _printSummary(String outputPath, Map> data) { + stdout.writeln('Wrote $outputPath'); + for (final entry in data.entries) { + stdout.writeln(' - ${entry.key}: ${entry.value.length} items'); + } +} diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..8aaa46a Binary files /dev/null and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png new file mode 100644 index 0000000..b749bfe Binary files /dev/null and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png new file mode 100644 index 0000000..88cfd48 Binary files /dev/null and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png new file mode 100644 index 0000000..eb9b4d7 Binary files /dev/null and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png new file mode 100644 index 0000000..d69c566 Binary files /dev/null and b/web/icons/Icon-maskable-512.png differ diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ce5c97a --- /dev/null +++ b/web/index.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + askiineverdie + + + + + + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..c2414ea --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,35 @@ +{ + "name": "askiineverdie", + "short_name": "askiineverdie", + "start_url": ".", + "display": "standalone", + "background_color": "#0175C2", + "theme_color": "#0175C2", + "description": "A new Flutter project.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "icons": [ + { + "src": "icons/Icon-192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "icons/Icon-512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000..d492d0d --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000..89930e8 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(askiineverdie LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "askiineverdie") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000..903f489 --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..8b6d468 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,11 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + + +void RegisterPlugins(flutter::PluginRegistry* registry) { +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..dc139d8 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000..b93c4c3 --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,23 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000..394917c --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000..c408e9d --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.example" "\0" + VALUE "FileDescription", "askiineverdie" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "askiineverdie" "\0" + VALUE "LegalCopyright", "Copyright (C) 2025 com.example. All rights reserved." "\0" + VALUE "OriginalFilename", "askiineverdie.exe" "\0" + VALUE "ProductName", "askiineverdie" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000..955ee30 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,71 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000..6da0652 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000..220e18e --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"askiineverdie", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000..66a65d1 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000..c04e20c Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000..153653e --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,14 @@ + + + + + PerMonitorV2 + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000..3a0b465 --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr) + -1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + input_length, utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000..3879d54 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000..60608d0 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000..e901dde --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_