diff --git a/.gitignore b/.gitignore index d9005f2..9a1a19b 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,27 @@ cython_debug/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# --------------------------------------------------------------------------- +# Xcode / Swift (iOS app) +# --------------------------------------------------------------------------- +## User-specific Xcode state (the project file itself IS committed) +xcuserdata/ +*.xcuserstate +*.xcuserdatad/ +DerivedData/ +build/ +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +## Swift Package Manager +.build/ +.swiftpm/ +Package.resolved + +## macOS +.DS_Store + diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f0cfa34 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project are recorded here. This file is the record +of **what's done**; the forward-looking, in-progress plan lives in +[`docs/BACKLOG.md`](docs/BACKLOG.md) and [`docs/ROADMAP.md`](docs/ROADMAP.md). + +The format is based on [Keep a Changelog](https://keepachangelog.com/). + +## [Unreleased] — in progress + +Tracked in `docs/BACKLOG.md`. Next up (launch-blocking `P0`): first-run +tutorial / rules card, VoiceOver accessibility, and App Store Connect setup +(screenshots, metadata, privacy, signing). Then polish: pixel-art dice sprites, +a bundled pixel font, and richer animation. + +## [0.1.0] — 2026-07-08 — Playable foundation + +The project was rebuilt from a Python teaching CLI into a native iOS +(Swift / SwiftUI) game. This milestone is a complete, playable foundation. + +### Added +- **Game engine** (`LiarsDice/Engine`): pure, UI-agnostic Swift rules — bids, + raises, challenges, reveal/tally, dice loss, elimination, win/lose — with a + deterministic injectable RNG and a full XCTest suite. +- **Bot AI** with three difficulty levels (Cabin Boy / Buccaneer / Sea Devil). +- **SwiftUI app**: main menu with settings, the game table (opponents, your cup, + bidding controls), the reveal, and win/lose screens, in a retro "pirate + tavern" style with dice drawn in SwiftUI. +- **Personality** preserved from the original: pirate narration and the + Crazy Pete defeat sequence. +- **App icon**: pixel-art bone die, generated by `tools/make_icon.py`. +- **Audio + haptics**: chiptune SFX (`tools/make_sfx.py`) via `AudioManager` + plus UIKit haptics through a `Feedback` facade; toggleable in the menu. +- **Tooling**: ready-to-open `LiarsDice.xcodeproj`, XcodeGen recovery spec, + `Makefile`, shared scheme, and stdlib-only asset generators in `tools/`. +- **Docs**: `README`, `docs/ROADMAP.md`, `docs/BACKLOG.md`, `docs/ART_DIRECTION.md`. + +### Changed +- Repository repurposed from a teaching project to a shippable game. The + original Python game was moved to `python-legacy/` and preserved. + +### Notes +- Not yet verified by a compiler in CI: GitHub-hosted Actions runners don't + provision on this account, so build/test verification happens locally + (`swift test` and an Xcode build). See `docs/ROADMAP.md`. diff --git a/LiarsDice.xcodeproj/project.pbxproj b/LiarsDice.xcodeproj/project.pbxproj new file mode 100644 index 0000000..e4fdcf6 --- /dev/null +++ b/LiarsDice.xcodeproj/project.pbxproj @@ -0,0 +1,322 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 1A0000000000000000000002 /* LiarsDice */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = LiarsDice; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 1A0000000000000000000007 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 1A0000000000000000000001 = { + isa = PBXGroup; + children = ( + 1A0000000000000000000002 /* LiarsDice */, + 1A0000000000000000000003 /* Products */, + ); + sourceTree = ""; + }; + 1A0000000000000000000003 /* Products */ = { + isa = PBXGroup; + children = ( + 1A0000000000000000000004 /* LiarsDice.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 1A0000000000000000000005 /* LiarsDice */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1A000000000000000000000A /* Build configuration list for PBXNativeTarget "LiarsDice" */; + buildPhases = ( + 1A0000000000000000000006 /* Sources */, + 1A0000000000000000000007 /* Frameworks */, + 1A0000000000000000000008 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 1A0000000000000000000002 /* LiarsDice */, + ); + name = LiarsDice; + productName = LiarsDice; + productReference = 1A0000000000000000000004 /* LiarsDice.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 1A0000000000000000000009 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 1600; + TargetAttributes = { + 1A0000000000000000000005 = { + CreatedOnToolsVersion = 16.0; + }; + }; + }; + buildConfigurationList = 1A000000000000000000000B /* Build configuration list for PBXProject "LiarsDice" */; + compatibilityVersion = "Xcode 15.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 1A0000000000000000000001; + productRefGroup = 1A0000000000000000000003 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 1A0000000000000000000005 /* LiarsDice */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 1A0000000000000000000008 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 1A0000000000000000000006 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 1A000000000000000000000C /* Debug */ = { + isa = XCBuildConfiguration; + 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++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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 = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 1A000000000000000000000D /* Release */ = { + isa = XCBuildConfiguration; + 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++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + 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 = 17.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 1A000000000000000000000E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.liarsdice; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1A000000000000000000000F /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = ""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.example.liarsdice; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 1A000000000000000000000A /* Build configuration list for PBXNativeTarget "LiarsDice" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A000000000000000000000E /* Debug */, + 1A000000000000000000000F /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 1A000000000000000000000B /* Build configuration list for PBXProject "LiarsDice" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1A000000000000000000000C /* Debug */, + 1A000000000000000000000D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 1A0000000000000000000009 /* Project object */; +} diff --git a/LiarsDice.xcodeproj/xcshareddata/xcschemes/LiarsDice.xcscheme b/LiarsDice.xcodeproj/xcshareddata/xcschemes/LiarsDice.xcscheme new file mode 100644 index 0000000..3c6b0fd --- /dev/null +++ b/LiarsDice.xcodeproj/xcshareddata/xcschemes/LiarsDice.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LiarsDice/App/LiarsDiceApp.swift b/LiarsDice/App/LiarsDiceApp.swift new file mode 100644 index 0000000..e5cdd05 --- /dev/null +++ b/LiarsDice/App/LiarsDiceApp.swift @@ -0,0 +1,36 @@ +import SwiftUI + +@main +struct LiarsDiceApp: App { + var body: some Scene { + WindowGroup { + RootView() + .preferredColorScheme(.dark) + } + } +} + +/// Switches between the main menu and an in-progress game. A new +/// ``GameViewModel`` is created each time a game starts, and torn down when the +/// player returns to shore. +struct RootView: View { + @State private var game: GameViewModel? + + var body: some View { + ZStack { + if let game { + GameTableView(vm: game) { + withAnimation(.easeInOut) { self.game = nil } + } + .transition(.move(edge: .trailing).combined(with: .opacity)) + } else { + MainMenuView { name, rules in + withAnimation(.easeInOut) { + game = GameViewModel(humanName: name, rules: rules) + } + } + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + } +} diff --git a/LiarsDice/Assets.xcassets/AccentColor.colorset/Contents.json b/LiarsDice/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..8056df2 --- /dev/null +++ b/LiarsDice/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.306", + "green" : "0.757", + "red" : "0.949" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LiarsDice/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/LiarsDice/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..cc1180a Binary files /dev/null and b/LiarsDice/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/LiarsDice/Assets.xcassets/AppIcon.appiconset/Contents.json b/LiarsDice/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/LiarsDice/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LiarsDice/Assets.xcassets/Contents.json b/LiarsDice/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/LiarsDice/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LiarsDice/Engine/Bid.swift b/LiarsDice/Engine/Bid.swift new file mode 100644 index 0000000..a537567 --- /dev/null +++ b/LiarsDice/Engine/Bid.swift @@ -0,0 +1,24 @@ +import Foundation + +/// A claim that *at least* `quantity` dice across the whole table are showing `face`. +/// +/// Raising the stakes means either claiming a larger quantity, or the same +/// quantity at a higher face value. This is the single rule that makes the +/// bidding ladder work. +public struct Bid: Equatable, Hashable, Sendable, Codable { + public let quantity: Int + public let face: Int + + public init(quantity: Int, face: Int) { + self.quantity = quantity + self.face = face + } + + /// A legal raise must increase the quantity, or keep the quantity while + /// increasing the face value. + public func isHigher(than other: Bid) -> Bool { + if quantity > other.quantity { return true } + if quantity == other.quantity && face > other.face { return true } + return false + } +} diff --git a/LiarsDice/Engine/BotAI.swift b/LiarsDice/Engine/BotAI.swift new file mode 100644 index 0000000..6b98b01 --- /dev/null +++ b/LiarsDice/Engine/BotAI.swift @@ -0,0 +1,84 @@ +import Foundation + +/// Decides what a bot does on its turn. The logic is a tightened-up version of +/// the original Python bots: estimate how many matching dice are probably out +/// there, challenge when a bid clearly outruns that estimate, otherwise nudge +/// the bid up. Difficulty scales how skeptical (and accurate) the bots are. +public enum BotAI { + + /// Choose an action for whichever player must act in `state` (assumed to be + /// a bot). Randomness flows through the injected generator so games are + /// reproducible in tests. + public static func action( + for state: GameState, + using rng: inout R + ) -> TurnAction { + guard let me = state.currentPlayer else { + return .challenge + } + let diceInPlay = state.diceInPlay + + // Opening bid: lean on what we actually rolled, plus a little padding. + guard let bid = state.currentBid else { + let face = Int.random(in: 1...6, using: &rng) + let own = me.cup.filter { $0 == face }.count + var quantity = own + Int.random(in: 0...1, using: &rng) + quantity = max(1, min(quantity, diceInPlay)) + return .openingBid(Bid(quantity: quantity, face: face)) + } + + // How many of the bid face do we expect across the table? + let wildBonus = (state.rules.onesAreWild && bid.face != 1) + ? me.cup.filter { $0 == 1 }.count : 0 + let own = me.cup.filter { $0 == bid.face }.count + wildBonus + let unknownDice = diceInPlay - me.cup.count + let perDie = (state.rules.onesAreWild && bid.face != 1) ? 2.0 / 6.0 : 1.0 / 6.0 + let expected = Double(own) + Double(unknownDice) * perDie + + let canRaiseQuantity = bid.quantity < diceInPlay + let canRaiseFace = bid.face < 6 + let skepticism = state.rules.difficulty.skepticism + let overage = Double(bid.quantity) - expected + + let challengeChance: Double + if !canRaiseQuantity && !canRaiseFace { + challengeChance = 1.0 // no higher bid possible + } else if overage > 1.0 { + challengeChance = min(1.0, 0.78 * skepticism) // bid looks greedy + } else if overage > 0.0 { + challengeChance = min(1.0, 0.30 * skepticism) // a little high + } else { + challengeChance = min(1.0, 0.06 * skepticism) // looks safe; rarely call + } + + if Double.random(in: 0..<1, using: &rng) < challengeChance { + return .challenge + } + + // Raise. Prefer bumping the face we already hold the most of when we're + // forced to pick, otherwise take the cheapest legal step up. + if canRaiseFace && canRaiseQuantity { + if Bool.random(using: &rng) { + return .raise(Bid(quantity: bid.quantity, face: bid.face + 1)) + } else { + return .raise(Bid(quantity: bid.quantity + 1, face: bestFace(for: me, default: bid.face))) + } + } else if canRaiseFace { + return .raise(Bid(quantity: bid.quantity, face: bid.face + 1)) + } else { + // face is maxed, so we must add quantity + return .raise(Bid(quantity: bid.quantity + 1, face: bid.face)) + } + } + + /// The face value this bot holds the most of (ties fall back to `default`). + /// Used to steer a quantity-raise toward a face the bot can actually back up. + private static func bestFace(for player: Player, default fallback: Int) -> Int { + var counts = [Int: Int]() + for die in player.cup { counts[die, default: 0] += 1 } + guard let best = counts.max(by: { $0.value < $1.value }), best.value > 0 else { + return fallback + } + return best.key + } +} diff --git a/LiarsDice/Engine/Difficulty.swift b/LiarsDice/Engine/Difficulty.swift new file mode 100644 index 0000000..abc6fe7 --- /dev/null +++ b/LiarsDice/Engine/Difficulty.swift @@ -0,0 +1,35 @@ +import Foundation + +/// How sharp the bots play. Higher difficulty bots are better at sniffing out +/// a bluff (they challenge closer to the true odds) and bluff a touch less. +public enum Difficulty: String, CaseIterable, Sendable, Codable { + case easy + case normal + case hard + + public var displayName: String { + switch self { + case .easy: return "Cabin Boy" + case .normal: return "Buccaneer" + case .hard: return "Sea Devil" + } + } + + public var blurb: String { + switch self { + case .easy: return "Forgiving foes who call your bluff only when it's blatant." + case .normal: return "A fair fight. The crew plays the odds." + case .hard: return "Sharp-eyed devils who count every die and rarely miss." + } + } + + /// Scales how readily a bot challenges a suspicious bid. + /// Higher means more skeptical (and more accurate). + var skepticism: Double { + switch self { + case .easy: return 0.6 + case .normal: return 1.0 + case .hard: return 1.4 + } + } +} diff --git a/LiarsDice/Engine/GameEvent.swift b/LiarsDice/Engine/GameEvent.swift new file mode 100644 index 0000000..3d61b0c --- /dev/null +++ b/LiarsDice/Engine/GameEvent.swift @@ -0,0 +1,15 @@ +import Foundation + +/// A narratable thing that just happened. Transition methods on ``GameState`` +/// return a list of these so the UI can animate and narrate the action +/// without re-deriving what changed. +public enum GameEvent: Equatable, Sendable { + case roundStarted(round: Int, diceInPlay: Int) + case openingBid(playerID: UUID, bid: Bid) + case raised(playerID: UUID, bid: Bid) + case challenged(challengerID: UUID, bidderID: UUID, bid: Bid) + case revealed(Reveal) + case lostDie(playerID: UUID, remaining: Int) + case eliminated(playerID: UUID) + case gameOver(humanWon: Bool) +} diff --git a/LiarsDice/Engine/GamePhase.swift b/LiarsDice/Engine/GamePhase.swift new file mode 100644 index 0000000..4ed01db --- /dev/null +++ b/LiarsDice/Engine/GamePhase.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Whose move it is and what the game is currently waiting on. The view layer +/// reads this to decide what to draw and which controls to enable. +public enum GamePhase: Equatable, Sendable { + /// The round just started; `starter` must make the opening bid (no + /// challenge is possible because there's nothing to challenge yet). + case opening(starter: UUID) + + /// Bidding is underway; it is `current`'s turn to raise or challenge. + case bidding(current: UUID) + + /// A challenge was made and the dice are on the table. + case reveal(Reveal) + + /// The game is finished. + case gameOver(humanWon: Bool) +} diff --git a/LiarsDice/Engine/GameRules.swift b/LiarsDice/Engine/GameRules.swift new file mode 100644 index 0000000..07e7fd2 --- /dev/null +++ b/LiarsDice/Engine/GameRules.swift @@ -0,0 +1,24 @@ +import Foundation + +/// The knobs that define a single game's setup. Defaults match the classic +/// pirate game: one human, three bots, five dice each. +public struct GameRules: Equatable, Sendable, Codable { + public var startingDice: Int + public var botCount: Int + /// When enabled, dice showing `1` count toward *any* bid face (the common + /// "ones are wild" house rule). Off by default to match the original game. + public var onesAreWild: Bool + public var difficulty: Difficulty + + public init( + startingDice: Int = 5, + botCount: Int = 3, + onesAreWild: Bool = false, + difficulty: Difficulty = .normal + ) { + self.startingDice = max(1, startingDice) + self.botCount = max(1, botCount) + self.onesAreWild = onesAreWild + self.difficulty = difficulty + } +} diff --git a/LiarsDice/Engine/GameState.swift b/LiarsDice/Engine/GameState.swift new file mode 100644 index 0000000..e4ee8fe --- /dev/null +++ b/LiarsDice/Engine/GameState.swift @@ -0,0 +1,293 @@ +import Foundation + +/// The complete, self-contained state of a Liar's Dice game. +/// +/// This type owns all the rules. State only changes through the `mutating` +/// transition methods, each of which returns the list of ``GameEvent``s it +/// produced so the view layer can animate and narrate. It is pure value-type +/// logic with no UI and no global state, which makes it trivial to unit test +/// and (later) to serialize for save games. +public struct GameState: Equatable, Sendable { + public private(set) var players: [Player] + public private(set) var rules: GameRules + public private(set) var phase: GamePhase + public private(set) var currentBid: Bid? + public private(set) var currentBidderID: UUID? + public private(set) var round: Int + + /// Full initializer, used by tests and (eventually) save-game loading. + /// Normal play should start a game with ``newGame(playerName:rules:using:)``. + public init( + players: [Player], + rules: GameRules, + phase: GamePhase, + currentBid: Bid?, + currentBidderID: UUID?, + round: Int + ) { + self.players = players + self.rules = rules + self.phase = phase + self.currentBid = currentBid + self.currentBidderID = currentBidderID + self.round = round + } + + // MARK: - Setup + + /// Default pirate crew names, handed out in order. + public static let defaultBotNames = [ + "One-Eyed Jack", "Salty Sue", "Blackbeard", "Peg-Leg Pete", + "Mad Morgan", "Calico Kate", "Cutthroat Cal", "Barnacle Bill" + ] + + /// Start a fresh game: one human plus `rules.botCount` bots, everyone with + /// `rules.startingDice` dice, a random starting player, and the first round + /// already rolled and waiting on the opening bid. + public static func newGame( + playerName: String, + rules: GameRules = GameRules(), + using rng: inout R + ) -> GameState { + var players: [Player] = [ + Player(name: playerName.isEmpty ? "You" : playerName, + isBot: false, + diceCount: rules.startingDice) + ] + for i in 0.. Player? { players.first { $0.id == id } } + + /// Whoever must act right now (the opener, or the current bidder), if any. + public var currentPlayerID: UUID? { + switch phase { + case .opening(let starter): return starter + case .bidding(let current): return current + case .reveal, .gameOver: return nil + } + } + + public var currentPlayer: Player? { + currentPlayerID.flatMap(player) + } + + public var isHumanTurn: Bool { + currentPlayer?.isHuman ?? false + } + + public var currentReveal: Reveal? { + if case .reveal(let r) = phase { return r } + return nil + } + + public var isGameOver: Bool { + if case .gameOver = phase { return true } + return false + } + + /// Count of dice across the table showing `face` (counting wild ones when + /// that rule is enabled and `face` isn't 1 itself). + public func tableCount(of face: Int) -> Int { + players.reduce(0) { acc, p in + var matches = p.cup.filter { $0 == face }.count + if rules.onesAreWild && face != 1 { + matches += p.cup.filter { $0 == 1 }.count + } + return acc + matches + } + } + + /// Is there any legal raise above the current bid? (You can't bid past + /// "every die in play is a 6".) + public func legalRaiseExists() -> Bool { + guard let bid = currentBid else { return true } + return bid.quantity < diceInPlay || bid.face < 6 + } + + /// The smallest bid that is strictly higher than the current bid, if one + /// exists. Handy as the default starting point for the human's raise UI. + public func minimumRaise() -> Bid? { + guard let bid = currentBid else { return Bid(quantity: 1, face: 1) } + if bid.face < 6 { + return Bid(quantity: bid.quantity, face: bid.face + 1) + } + if bid.quantity < diceInPlay { + return Bid(quantity: bid.quantity + 1, face: 1) + } + return nil + } + + // MARK: - Transitions + + /// Apply a turn action (from a human tap or a bot decision) during bidding + /// or opening. Returns the events produced. + public mutating func apply(_ action: TurnAction) -> [GameEvent] { + switch action { + case .openingBid(let bid): return submitOpeningBid(bid) + case .raise(let bid): return raise(bid) + case .challenge: return challenge() + } + } + + /// The starting player commits the round's first bid. + public mutating func submitOpeningBid(_ bid: Bid) -> [GameEvent] { + guard case .opening(let starter) = phase else { return [] } + currentBid = bid + currentBidderID = starter + phase = .bidding(current: nextActivePlayerID(after: starter)) + return [.openingBid(playerID: starter, bid: bid)] + } + + /// The current player raises the bid. The caller is responsible for only + /// passing a strictly-higher bid; an illegal bid is ignored. + public mutating func raise(_ bid: Bid) -> [GameEvent] { + guard case .bidding(let current) = phase, let old = currentBid else { return [] } + guard bid.isHigher(than: old) else { return [] } + currentBid = bid + currentBidderID = current + phase = .bidding(current: nextActivePlayerID(after: current)) + return [.raised(playerID: current, bid: bid)] + } + + /// The current player calls the previous bidder a liar. Dice are tallied + /// immediately and the loser is determined; the state moves to `.reveal`. + public mutating func challenge() -> [GameEvent] { + guard case .bidding(let challenger) = phase, + let bid = currentBid, + let bidder = currentBidderID else { return [] } + + let total = tableCount(of: bid.face) + let bidWasGood = total >= bid.quantity + let loser = bidWasGood ? challenger : bidder + + let reveal = Reveal( + bid: bid, + challengerID: challenger, + bidderID: bidder, + total: total, + bidWasGood: bidWasGood, + loserID: loser + ) + phase = .reveal(reveal) + return [ + .challenged(challengerID: challenger, bidderID: bidder, bid: bid), + .revealed(reveal) + ] + } + + /// After the reveal has been shown, settle up: the loser drops a die, the + /// game checks for a winner, and (if play continues) the next round is + /// rolled with the loser starting. + public mutating func resolveReveal( + using rng: inout R + ) -> [GameEvent] { + guard case .reveal(let reveal) = phase, + let loserIndex = players.firstIndex(where: { $0.id == reveal.loserID }) + else { return [] } + + players[loserIndex].diceCount -= 1 + var events: [GameEvent] = [ + .lostDie(playerID: reveal.loserID, remaining: players[loserIndex].diceCount) + ] + + let loserEliminated = players[loserIndex].isEliminated + if loserEliminated { + players[loserIndex].cup = [] + events.append(.eliminated(playerID: reveal.loserID)) + } + + // End conditions: the human is out, or only one player remains. + if let human = humanPlayer, human.isEliminated { + phase = .gameOver(humanWon: false) + events.append(.gameOver(humanWon: false)) + return events + } + let remaining = activePlayers + if remaining.count <= 1 { + let humanWon = remaining.first?.isHuman ?? false + phase = .gameOver(humanWon: humanWon) + events.append(.gameOver(humanWon: humanWon)) + return events + } + + // Otherwise, roll a new round. The loser starts (or, if they were just + // knocked out, the next player still in the game). + let nextStarter = loserEliminated + ? nextActivePlayerID(after: reveal.loserID) + : reveal.loserID + events.append(contentsOf: beginRound(starter: nextStarter, using: &rng)) + return events + } + + // MARK: - Round setup + + private mutating func beginRound( + starter: UUID, + using rng: inout R + ) -> [GameEvent] { + round += 1 + for index in players.indices { + if players[index].isEliminated { + players[index].cup = [] + } else { + players[index].cup = GameState.roll(players[index].diceCount, using: &rng) + } + } + currentBid = nil + currentBidderID = nil + phase = .opening(starter: starter) + return [.roundStarted(round: round, diceInPlay: diceInPlay)] + } + + // MARK: - Helpers + + private static func roll( + _ count: Int, + using rng: inout R + ) -> [Int] { + (0.. UUID { + guard let start = players.firstIndex(where: { $0.id == id }) else { + return id + } + let count = players.count + for step in 1...count { + let candidate = players[(start + step) % count] + if !candidate.isEliminated { + return candidate.id + } + } + return id + } +} diff --git a/LiarsDice/Engine/Player.swift b/LiarsDice/Engine/Player.swift new file mode 100644 index 0000000..8b38bb1 --- /dev/null +++ b/LiarsDice/Engine/Player.swift @@ -0,0 +1,29 @@ +import Foundation + +/// One participant at the table — either the human or a bot. +public struct Player: Identifiable, Equatable, Sendable, Codable { + public let id: UUID + public var name: String + public let isBot: Bool + /// How many dice this player still owns. Lose a round, lose a die. + public var diceCount: Int + /// The dice rolled this round, hidden from everyone else. + public var cup: [Int] + + public init( + id: UUID = UUID(), + name: String, + isBot: Bool, + diceCount: Int, + cup: [Int] = [] + ) { + self.id = id + self.name = name + self.isBot = isBot + self.diceCount = diceCount + self.cup = cup + } + + public var isHuman: Bool { !isBot } + public var isEliminated: Bool { diceCount <= 0 } +} diff --git a/LiarsDice/Engine/Reveal.swift b/LiarsDice/Engine/Reveal.swift new file mode 100644 index 0000000..33d9c87 --- /dev/null +++ b/LiarsDice/Engine/Reveal.swift @@ -0,0 +1,32 @@ +import Foundation + +/// The outcome of a challenge: every cup is shown, the matching dice are +/// counted, and someone is proven wrong. +public struct Reveal: Equatable, Sendable, Codable { + public let bid: Bid + public let challengerID: UUID + public let bidderID: UUID + /// Total dice across the whole table that matched the bid's face + /// (including wild ones, if that rule is on). + public let total: Int + /// `true` if there really were at least `bid.quantity` matching dice. + public let bidWasGood: Bool + /// Whoever was proven wrong and loses a die. + public let loserID: UUID + + public init( + bid: Bid, + challengerID: UUID, + bidderID: UUID, + total: Int, + bidWasGood: Bool, + loserID: UUID + ) { + self.bid = bid + self.challengerID = challengerID + self.bidderID = bidderID + self.total = total + self.bidWasGood = bidWasGood + self.loserID = loserID + } +} diff --git a/LiarsDice/Engine/SeededRandomNumberGenerator.swift b/LiarsDice/Engine/SeededRandomNumberGenerator.swift new file mode 100644 index 0000000..22be2e8 --- /dev/null +++ b/LiarsDice/Engine/SeededRandomNumberGenerator.swift @@ -0,0 +1,22 @@ +import Foundation + +/// A small, fast, fully deterministic generator (SplitMix64). +/// +/// Used by the unit tests for reproducible games, and available to the app for +/// future "daily challenge" style seeded rounds. Given the same seed it always +/// produces the same sequence. +public struct SeededRandomNumberGenerator: RandomNumberGenerator, Sendable { + private var state: UInt64 + + public init(seed: UInt64) { + self.state = seed + } + + public mutating func next() -> UInt64 { + state = state &+ 0x9E3779B97F4A7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } +} diff --git a/LiarsDice/Engine/TurnAction.swift b/LiarsDice/Engine/TurnAction.swift new file mode 100644 index 0000000..2ec684c --- /dev/null +++ b/LiarsDice/Engine/TurnAction.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A single decision a player can make on their turn. Both the human UI and +/// the bot AI produce one of these, and ``GameState`` applies it. +public enum TurnAction: Equatable, Sendable { + case openingBid(Bid) + case raise(Bid) + case challenge +} diff --git a/LiarsDice/Resources/Audio/bid.wav b/LiarsDice/Resources/Audio/bid.wav new file mode 100644 index 0000000..8995d1a Binary files /dev/null and b/LiarsDice/Resources/Audio/bid.wav differ diff --git a/LiarsDice/Resources/Audio/challenge.wav b/LiarsDice/Resources/Audio/challenge.wav new file mode 100644 index 0000000..abc7d80 Binary files /dev/null and b/LiarsDice/Resources/Audio/challenge.wav differ diff --git a/LiarsDice/Resources/Audio/lose.wav b/LiarsDice/Resources/Audio/lose.wav new file mode 100644 index 0000000..53fb5a9 Binary files /dev/null and b/LiarsDice/Resources/Audio/lose.wav differ diff --git a/LiarsDice/Resources/Audio/lose_die.wav b/LiarsDice/Resources/Audio/lose_die.wav new file mode 100644 index 0000000..599771c Binary files /dev/null and b/LiarsDice/Resources/Audio/lose_die.wav differ diff --git a/LiarsDice/Resources/Audio/reveal_good.wav b/LiarsDice/Resources/Audio/reveal_good.wav new file mode 100644 index 0000000..6b5a743 Binary files /dev/null and b/LiarsDice/Resources/Audio/reveal_good.wav differ diff --git a/LiarsDice/Resources/Audio/reveal_lie.wav b/LiarsDice/Resources/Audio/reveal_lie.wav new file mode 100644 index 0000000..0df0223 Binary files /dev/null and b/LiarsDice/Resources/Audio/reveal_lie.wav differ diff --git a/LiarsDice/Resources/Audio/roll.wav b/LiarsDice/Resources/Audio/roll.wav new file mode 100644 index 0000000..f7ab0d9 Binary files /dev/null and b/LiarsDice/Resources/Audio/roll.wav differ diff --git a/LiarsDice/Resources/Audio/win.wav b/LiarsDice/Resources/Audio/win.wav new file mode 100644 index 0000000..a6427ce Binary files /dev/null and b/LiarsDice/Resources/Audio/win.wav differ diff --git a/LiarsDice/Resources/Flavor.swift b/LiarsDice/Resources/Flavor.swift new file mode 100644 index 0000000..c1fe311 --- /dev/null +++ b/LiarsDice/Resources/Flavor.swift @@ -0,0 +1,65 @@ +import Foundation + +/// All the pirate-flavored writing lives here, kept apart from the rules engine +/// and the views. This is where the original game's personality is preserved. +enum Flavor { + + static let tagline = "Lie well. Call the liars. Don't be the last scallywag holding nothing." + + static let rules = """ + Everyone hides their dice. On yer turn ye either RAISE the bid — claim there \ + be MORE dice of a value, or the SAME number of a HIGHER value across every \ + cup — or ye CHALLENGE the last bid if ye smell a lie. + + On a challenge, all dice be counted. Guess wrong and ye lose a die. Lose \ + all yer dice and ye be OUT. Last pirate standing wins the gold! + """ + + static let victoryLines = [ + "Well, blow me down! Ye actually WON?!", + "Ye stared the sea devils in the eye and out-lied every last one of 'em!", + "Take yer gold and get off me ship before I change me mind! HAHAHA!" + ] + + /// The legendary defeat sequence from the original game, lovingly preserved. + /// Shown when you lose your last die and are sentenced to the Flying Dutchman. + static let crazyPeteIntro = """ + Bosun: Well... ye really made a mistake. Now you're stuck here with us for \ + the rest of forever. I hope ye like Crazy Pete... because you'll be listening \ + to him for a loooooong time! Hahahaha! + + You're escorted to the lower deck and handed a toothbrush. As you near the \ + bilge, you start to hear someone talking... + """ + + static let crazyPeteMonologue = """ + Crazy Pete: I go to the store. A car is parked. Many cars are parked or \ + moving. Some are blue. Some are tan. They have windows. In the store, there \ + are items for sale. These include such things as soap, detergent, magazines, \ + and lettuce. You can enhance your life with these products. Soap can be used \ + for bathing, be it in a bathtub or in a shower. Lettuce is a vegetable. It is \ + usually green and leafy, and is the main ingredient of salads... + + Crazy Pete: If I drive around, I sometimes notice the houses and buildings \ + all around. Houses can be built from different kinds of materials. The most \ + common types are brick, wood, and vinyl siding. Houses have lawns that need \ + to be mowed regularly. Most people use riding lawnmowers to do this. Many \ + families designate the lawnmowing responsibility to a teenager in the \ + household... + + Crazy Pete: Now I will talk about farm land. Farm land can be identified by \ + some common features. They almost always consist of a very large patch of \ + dirt with small green plants lined up in very long rows. Some different types \ + of crops are soybeans, cotton, corn, tomatoes, and lettuce (which I mentioned \ + earlier). A very versatile vegetable is the potato. It can be baked, mashed, \ + or cut into thin strips and fried... + + Crazy Pete: I will end my writing here. I have tried to make it very boring, \ + and I hope I have succeeded. There are plenty of boring documents available \ + for you to read. Check your public library for more information. You can also \ + find boring materials at a bookstore or in magazines (which I mentioned \ + earlier)... + """ + + static let crazyPeteOutro = "Welcome to the Flying Dutchman. I guess this is just life now... Arrr." +} diff --git a/LiarsDice/Services/Feedback.swift b/LiarsDice/Services/Feedback.swift new file mode 100644 index 0000000..1f3f508 --- /dev/null +++ b/LiarsDice/Services/Feedback.swift @@ -0,0 +1,114 @@ +import Foundation +import AVFoundation +import UIKit + +/// The eight chiptune effects bundled in LiarsDice/Resources/Audio. +enum GameSound: String, CaseIterable { + case roll, bid, challenge + case revealGood = "reveal_good" + case revealLie = "reveal_lie" + case loseDie = "lose_die" + case win, lose +} + +/// Reads the sound/haptics toggles from the same UserDefaults keys the menu +/// writes via `@AppStorage`. Both default to on. +enum FeedbackSettings { + static var soundEnabled: Bool { + UserDefaults.standard.object(forKey: "soundEnabled") as? Bool ?? true + } + static var hapticsEnabled: Bool { + UserDefaults.standard.object(forKey: "hapticsEnabled") as? Bool ?? true + } +} + +/// Loads and plays the bundled sound effects. Players are cached so repeated +/// plays are instant, and the audio session is `.ambient` + `.mixWithOthers` +/// so the game never interrupts the player's own music and respects the +/// silent switch. +@MainActor +final class AudioManager { + static let shared = AudioManager() + + private var players: [GameSound: AVAudioPlayer] = [:] + private var sessionConfigured = false + + private func configureSession() { + guard !sessionConfigured else { return } + sessionConfigured = true + let session = AVAudioSession.sharedInstance() + try? session.setCategory(.ambient, options: [.mixWithOthers]) + try? session.setActive(true) + } + + private func url(for sound: GameSound) -> URL? { + let name = sound.rawValue + return Bundle.main.url(forResource: name, withExtension: "wav") + ?? Bundle.main.url(forResource: name, withExtension: "wav", subdirectory: "Audio") + } + + private func player(for sound: GameSound) -> AVAudioPlayer? { + if let cached = players[sound] { return cached } + guard let url = url(for: sound), let player = try? AVAudioPlayer(contentsOf: url) else { + return nil + } + player.prepareToPlay() + players[sound] = player + return player + } + + func play(_ sound: GameSound) { + guard FeedbackSettings.soundEnabled else { return } + configureSession() + guard let player = player(for: sound) else { return } + player.currentTime = 0 + player.play() + } + + /// Preload every effect so the first in-game play has no hitch. + func warmUp() { + for sound in GameSound.allCases { _ = player(for: sound) } + } +} + +/// Thin wrapper over UIKit's haptic generators, gated by the user setting. +@MainActor +enum Haptics { + static func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { + guard FeedbackSettings.hapticsEnabled else { return } + UIImpactFeedbackGenerator(style: style).impactOccurred() + } + + static func notify(_ type: UINotificationFeedbackGenerator.FeedbackType) { + guard FeedbackSettings.hapticsEnabled else { return } + UINotificationFeedbackGenerator().notificationOccurred(type) + } + + static func selection() { + guard FeedbackSettings.hapticsEnabled else { return } + UISelectionFeedbackGenerator().selectionChanged() + } +} + +/// Semantic game moments. The view model fires these; this layer maps each to a +/// matching sound + haptic so callers don't worry about either. +enum GameFeedback { + case roll, bid, challenge, revealGood, revealLie, loseDie, eliminated, win, lose +} + +@MainActor +enum Feedback { + static func play(_ feedback: GameFeedback) { + switch feedback { + case .roll: Haptics.impact(.light); AudioManager.shared.play(.roll) + case .bid: Haptics.selection(); AudioManager.shared.play(.bid) + case .challenge: Haptics.impact(.heavy); AudioManager.shared.play(.challenge) + case .revealGood: Haptics.notify(.success); AudioManager.shared.play(.revealGood) + case .revealLie: Haptics.notify(.warning); AudioManager.shared.play(.revealLie) + case .loseDie: Haptics.impact(.medium); AudioManager.shared.play(.loseDie) + case .eliminated: Haptics.notify(.error) + case .win: Haptics.notify(.success); AudioManager.shared.play(.win) + case .lose: Haptics.notify(.error); AudioManager.shared.play(.lose) + } + } +} diff --git a/LiarsDice/Theme/Theme.swift b/LiarsDice/Theme/Theme.swift new file mode 100644 index 0000000..6b4b20f --- /dev/null +++ b/LiarsDice/Theme/Theme.swift @@ -0,0 +1,109 @@ +import SwiftUI + +/// Central place for the game's retro "pirate tavern" look: a tight, high +/// contrast palette, chunky monospaced type, and a couple of reusable +/// components so every screen feels like one game. +/// +/// The aesthetic is deliberately a step up from plain text — bold flat colors, +/// thick ink borders, carved-bone dice — while staying lightweight (everything +/// is drawn by SwiftUI, no image assets required). Swapping in a true pixel +/// font like "Press Start 2P" later is a one-line change in `RetroFont`. + +extension Color { + init(hex: UInt) { + let r = Double((hex >> 16) & 0xFF) / 255.0 + let g = Double((hex >> 8) & 0xFF) / 255.0 + let b = Double(hex & 0xFF) / 255.0 + self.init(.sRGB, red: r, green: g, blue: b, opacity: 1.0) + } +} + +enum Palette { + static let night = Color(hex: 0x14151F) + static let table = Color(hex: 0x2A6F5A) + static let tableDark = Color(hex: 0x1E5142) + static let wood = Color(hex: 0x4A3526) + static let woodDark = Color(hex: 0x2A1C12) + static let parchment = Color(hex: 0xE9D8A6) + static let gold = Color(hex: 0xF2C14E) + static let bone = Color(hex: 0xF6F0E2) + static let pip = Color(hex: 0x23201A) + static let danger = Color(hex: 0xD1495B) + static let raise = Color(hex: 0x3D8BFF) + static let ink = Color(hex: 0x241B12) + static let cream = Color(hex: 0xF7F3E8) +} + +enum RetroFont { + // To use a real pixel font, drop the .ttf into the app, register it in + // Info.plist (Fonts provided by application), and return Font.custom here. + static func title(_ size: CGFloat) -> Font { .system(size: size, weight: .black, design: .monospaced) } + static func heavy(_ size: CGFloat) -> Font { .system(size: size, weight: .heavy, design: .monospaced) } + static func body(_ size: CGFloat) -> Font { .system(size: size, weight: .semibold, design: .monospaced) } +} + +/// The felt-and-wood table backdrop shared by the menu and the game. +struct TableBackground: View { + var body: some View { + ZStack { + Palette.night + RadialGradient( + colors: [Palette.table, Palette.tableDark], + center: .center, + startRadius: 40, + endRadius: 520 + ) + .ignoresSafeArea() + // A subtle wooden frame around the felt. + RoundedRectangle(cornerRadius: 0) + .stroke(Palette.woodDark, lineWidth: 18) + .ignoresSafeArea() + } + .ignoresSafeArea() + } +} + +/// A chunky, ink-bordered button that drops down a few points when pressed. +struct RetroButtonStyle: ButtonStyle { + var fill: Color = Palette.gold + var textColor: Color = Palette.ink + var fontSize: CGFloat = 18 + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(RetroFont.heavy(fontSize)) + .foregroundStyle(textColor) + .padding(.vertical, 12) + .padding(.horizontal, 18) + .frame(maxWidth: .infinity) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(fill) + .shadow(color: Palette.ink.opacity(0.5), + radius: 0, x: 0, y: configuration.isPressed ? 1 : 4) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Palette.ink, lineWidth: 3) + ) + .offset(y: configuration.isPressed ? 3 : 0) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} + +/// A parchment panel with a thick ink border. +private struct PanelModifier: ViewModifier { + var fill: Color + func body(content: Content) -> some View { + content + .padding(14) + .background(RoundedRectangle(cornerRadius: 12).fill(fill)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Palette.ink, lineWidth: 3)) + } +} + +extension View { + func panel(_ fill: Color = Palette.parchment) -> some View { + modifier(PanelModifier(fill: fill)) + } +} diff --git a/LiarsDice/ViewModels/GameViewModel.swift b/LiarsDice/ViewModels/GameViewModel.swift new file mode 100644 index 0000000..8d73f50 --- /dev/null +++ b/LiarsDice/ViewModels/GameViewModel.swift @@ -0,0 +1,210 @@ +import Foundation +import SwiftUI +import Combine + +/// Bridges the pure `GameState` engine to SwiftUI. It owns the game, runs bot +/// turns on a paced timer so the table feels alive, and turns engine events +/// into pirate narration. All mutation happens on the main actor so the +/// published state and the views stay in lockstep. +@MainActor +final class GameViewModel: ObservableObject { + + @Published private(set) var state: GameState + /// Newest narration line, shown big in the center of the table. + @Published private(set) var headline: String = "" + /// Rolling log of recent events (oldest first), capped for memory. + @Published private(set) var log: [String] = [] + /// True while bots are acting or a reveal is on screen — disables controls. + @Published private(set) var isBusy: Bool = false + /// Bumped at the start of each round to trigger the dice-roll animation. + @Published private(set) var rollNonce: Int = 0 + + let rules: GameRules + let humanName: String + private var rng = SystemRandomNumberGenerator() + + init(humanName: String, rules: GameRules) { + self.rules = rules + self.humanName = humanName + var generator = SystemRandomNumberGenerator() + self.state = GameState.newGame(playerName: humanName, rules: rules, using: &generator) + self.rng = generator + rollNonce = 1 + headline = "Round 1 — \(state.diceInPlay) dice in play. Shake yer cups!" + log = [headline] + AudioManager.shared.warmUp() + start() + } + + // MARK: - Convenience for the views + + var phase: GamePhase { state.phase } + var currentBid: Bid? { state.currentBid } + var human: Player? { state.humanPlayer } + var opponents: [Player] { state.opponents } + var reveal: Reveal? { state.currentReveal } + var diceInPlay: Int { state.diceInPlay } + + var isHumanOpening: Bool { + if case .opening(let starter) = state.phase { + return state.player(starter)?.isHuman ?? false + } + return false + } + var isHumanBidding: Bool { + if case .bidding(let current) = state.phase { + return state.player(current)?.isHuman ?? false + } + return false + } + var awaitingHuman: Bool { (isHumanOpening || isHumanBidding) && !isBusy } + var canChallenge: Bool { isHumanBidding && currentBid != nil && !isBusy } + var canRaise: Bool { (isHumanBidding || isHumanOpening) && state.legalRaiseExists() && !isBusy } + + var gameOverHumanWon: Bool? { + if case .gameOver(let won) = state.phase { return won } + return nil + } + + func name(_ id: UUID) -> String { state.player(id)?.name ?? "Someone" } + + func bidText(_ bid: Bid) -> String { + let noun = bid.quantity == 1 ? "die" : "dice" + return "\(bid.quantity) \(noun) showing \(bid.face)" + } + + // MARK: - Human actions + + func submitOpeningBid(_ bid: Bid) { + guard awaitingHuman, isHumanOpening else { return } + narrate(state.submitOpeningBid(bid)) + runTurns() + } + + func raise(_ bid: Bid) { + guard awaitingHuman, isHumanBidding else { return } + narrate(state.raise(bid)) + runTurns() + } + + func challenge() { + guard canChallenge else { return } + narrate(state.challenge()) + runTurns() + } + + func rematch() { + var generator = SystemRandomNumberGenerator() + state = GameState.newGame(playerName: humanName, rules: rules, using: &generator) + rng = generator + rollNonce += 1 + log = [] + headline = "Round 1 — \(state.diceInPlay) dice in play. Shake yer cups!" + log = [headline] + start() + } + + // MARK: - Turn engine + + private func start() { + Task { await runLoop() } + } + + private func runTurns() { + Task { await runLoop() } + } + + /// Advance through bot turns and reveals until it's the human's move again, + /// or the game ends. + private func runLoop() async { + isBusy = true + loop: while true { + switch state.phase { + case .opening(let starter): + guard let player = state.player(starter) else { break loop } + if player.isBot { + await pause(.think) + let action = BotAI.action(for: state, using: &rng) + narrate(state.apply(action)) + } else { + break loop // human's move + } + + case .bidding(let current): + guard let player = state.player(current) else { break loop } + if player.isBot { + await pause(.think) + let action = BotAI.action(for: state, using: &rng) + narrate(state.apply(action)) + } else { + break loop // human's move + } + + case .reveal: + await pause(.reveal) + let events = state.resolveReveal(using: &rng) + narrate(events) + await pause(.round) + + case .gameOver: + break loop + } + } + isBusy = false + } + + private enum Beat { case think, reveal, round } + + private func pause(_ beat: Beat) async { + let seconds: Double + switch beat { + case .think: seconds = 0.9 + case .reveal: seconds = 2.8 + case .round: seconds = 1.2 + } + try? await Task.sleep(for: .seconds(seconds)) + } + + // MARK: - Narration + + private func narrate(_ events: [GameEvent]) { + for event in events { + let line: String + switch event { + case .roundStarted(let round, let dice): + rollNonce += 1 + Feedback.play(.roll) + line = "Round \(round) — \(dice) dice in play. Shake yer cups!" + case .openingBid(let id, let bid): + Feedback.play(.bid) + line = "\(name(id)) opens the biddin': \(bidText(bid))." + case .raised(let id, let bid): + Feedback.play(.bid) + line = "\(name(id)) raises to \(bidText(bid))." + case .challenged(let challenger, let bidder, _): + Feedback.play(.challenge) + line = "\(name(challenger)) calls \(name(bidder)) a LIAR!" + case .revealed(let reveal): + Feedback.play(reveal.bidWasGood ? .revealGood : .revealLie) + let verdict = reveal.bidWasGood ? "The bid was GOOD!" : "It was a LIE!" + line = "Count 'em up — \(reveal.total) showing \(reveal.bid.face). \(verdict)" + case .lostDie(let id, let remaining): + Feedback.play(.loseDie) + let noun = remaining == 1 ? "die" : "dice" + line = "\(name(id)) loses a die — \(remaining) \(noun) left." + case .eliminated(let id): + Feedback.play(.eliminated) + line = "\(name(id)) is OUT of the game!" + case .gameOver(let humanWon): + Feedback.play(humanWon ? .win : .lose) + line = humanWon ? "Last pirate standing — ye WIN!" + : "Ye've lost yer last die..." + } + headline = line + log.append(line) + } + if log.count > 40 { + log.removeFirst(log.count - 40) + } + } +} diff --git a/LiarsDice/Views/BidControlView.swift b/LiarsDice/Views/BidControlView.swift new file mode 100644 index 0000000..ca2ccbd --- /dev/null +++ b/LiarsDice/Views/BidControlView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +/// The human's bidding panel: pick a quantity and a face, then confirm. Works +/// for both the opening bid and a raise; in raise mode it refuses anything that +/// isn't strictly higher than the standing bid. +struct BidControlView: View { + enum Mode: Equatable { + case opening + case raise(Bid) + } + + let mode: Mode + let diceInPlay: Int + let onBid: (Bid) -> Void + + @State private var quantity: Int + @State private var face: Int + + init(mode: Mode, diceInPlay: Int, onBid: @escaping (Bid) -> Void) { + self.mode = mode + self.diceInPlay = diceInPlay + self.onBid = onBid + switch mode { + case .opening: + _quantity = State(initialValue: min(2, max(1, diceInPlay))) + _face = State(initialValue: 5) + case .raise(let current): + if current.face < 6 { + _quantity = State(initialValue: current.quantity) + _face = State(initialValue: current.face + 1) + } else { + _quantity = State(initialValue: min(current.quantity + 1, diceInPlay)) + _face = State(initialValue: 1) + } + } + } + + private var bid: Bid { Bid(quantity: quantity, face: face) } + + private var isValid: Bool { + guard quantity >= 1, quantity <= diceInPlay, (1...6).contains(face) else { return false } + switch mode { + case .opening: return true + case .raise(let current): return bid.isHigher(than: current) + } + } + + private var confirmTitle: String { + if case .opening = mode { return "Make Bid" } + return "Raise" + } + + var body: some View { + VStack(spacing: 12) { + HStack(spacing: 18) { + stepper(title: "HOW MANY") { + Text("\(quantity)") + .font(RetroFont.title(30)) + .foregroundStyle(Palette.ink) + .frame(minWidth: 40) + } onMinus: { + quantity = max(1, quantity - 1) + } onPlus: { + quantity = min(diceInPlay, quantity + 1) + } + + stepper(title: "SHOWING") { + DieView(value: face, size: 34) + } onMinus: { + face = max(1, face - 1) + } onPlus: { + face = min(6, face + 1) + } + } + + Button(confirmTitle) { onBid(bid) } + .buttonStyle(RetroButtonStyle(fill: isValid ? Palette.gold : Palette.parchment)) + .disabled(!isValid) + .opacity(isValid ? 1 : 0.5) + } + .panel() + } + + @ViewBuilder + private func stepper( + title: String, + @ViewBuilder value: () -> Content, + onMinus: @escaping () -> Void, + onPlus: @escaping () -> Void + ) -> some View { + VStack(spacing: 6) { + Text(title) + .font(RetroFont.body(11)) + .foregroundStyle(Palette.ink.opacity(0.7)) + HStack(spacing: 8) { + roundButton("–", action: onMinus) + value() + roundButton("+", action: onPlus) + } + } + } + + private func roundButton(_ label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(RetroFont.title(22)) + .foregroundStyle(Palette.cream) + .frame(width: 38, height: 38) + .background(Circle().fill(Palette.wood)) + .overlay(Circle().stroke(Palette.ink, lineWidth: 2)) + } + .buttonStyle(.plain) + } +} diff --git a/LiarsDice/Views/DieView.swift b/LiarsDice/Views/DieView.swift new file mode 100644 index 0000000..631612d --- /dev/null +++ b/LiarsDice/Views/DieView.swift @@ -0,0 +1,83 @@ +import SwiftUI + +/// Pip positions in a unit square (0...1) for each die face. +enum PipLayout { + static func positions(for value: Int) -> [(x: CGFloat, y: CGFloat)] { + let lo: CGFloat = 0.28, mid: CGFloat = 0.5, hi: CGFloat = 0.72 + switch max(1, min(6, value)) { + case 1: return [(mid, mid)] + case 2: return [(lo, lo), (hi, hi)] + case 3: return [(lo, lo), (mid, mid), (hi, hi)] + case 4: return [(lo, lo), (hi, lo), (lo, hi), (hi, hi)] + case 5: return [(lo, lo), (hi, lo), (mid, mid), (lo, hi), (hi, hi)] + default: return [(lo, lo), (hi, lo), (lo, mid), (hi, mid), (lo, hi), (hi, hi)] + } + } +} + +/// A single carved-bone die showing `value`, drawn with SwiftUI shapes. +/// Bumping `rollTrigger` makes it tumble — used at the start of each round. +struct DieView: View { + let value: Int + var size: CGFloat = 44 + var fill: Color = Palette.bone + var pipColor: Color = Palette.pip + var rollTrigger: Int = 0 + + @State private var spin: Double = 0 + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: size * 0.2) + .fill(fill) + ForEach(Array(PipLayout.positions(for: value).enumerated()), id: \.offset) { _, pip in + Circle() + .fill(pipColor) + .frame(width: size * 0.17, height: size * 0.17) + .position(x: pip.x * size, y: pip.y * size) + } + } + .frame(width: size, height: size) + .overlay( + RoundedRectangle(cornerRadius: size * 0.2) + .stroke(Palette.ink, lineWidth: max(2, size * 0.07)) + ) + .rotation3DEffect(.degrees(spin), axis: (x: 1, y: 1, z: 0.2)) + .shadow(color: Palette.ink.opacity(0.35), radius: 0, x: 0, y: 2) + .onChange(of: rollTrigger) { _, _ in + withAnimation(.spring(response: 0.55, dampingFraction: 0.55)) { + spin += 360 + } + } + } +} + +/// A face-down die (an opponent's hidden cup), with a little bone marker. +struct FaceDownDie: View { + var size: CGFloat = 26 + + var body: some View { + RoundedRectangle(cornerRadius: size * 0.2) + .fill(Palette.wood) + .overlay( + Circle() + .fill(Palette.parchment.opacity(0.55)) + .frame(width: size * 0.24, height: size * 0.24) + ) + .overlay( + RoundedRectangle(cornerRadius: size * 0.2) + .stroke(Palette.ink, lineWidth: max(2, size * 0.07)) + ) + .frame(width: size, height: size) + .shadow(color: Palette.ink.opacity(0.35), radius: 0, x: 0, y: 2) + } +} + +#Preview { + HStack(spacing: 8) { + ForEach(1...6, id: \.self) { DieView(value: $0, size: 40) } + FaceDownDie(size: 40) + } + .padding() + .background(Palette.table) +} diff --git a/LiarsDice/Views/GameOverView.swift b/LiarsDice/Views/GameOverView.swift new file mode 100644 index 0000000..917c619 --- /dev/null +++ b/LiarsDice/Views/GameOverView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +/// Shown when the game ends. Winning gets the bosun's grudging congratulations; +/// losing your last die sends you to the Flying Dutchman to endure Crazy Pete. +struct GameOverView: View { + let humanWon: Bool + @ObservedObject var vm: GameViewModel + var onExit: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(0.78).ignoresSafeArea() + if humanWon { victory } else { defeat } + } + } + + private var victory: some View { + VStack(spacing: 18) { + Text("⚓︎").font(.system(size: 56)) + Text("YE WIN!") + .font(RetroFont.title(40)) + .foregroundStyle(Palette.gold) + VStack(spacing: 8) { + ForEach(Flavor.victoryLines, id: \.self) { line in + Text(line) + .font(RetroFont.body(14)) + .foregroundStyle(Palette.ink) + .multilineTextAlignment(.center) + } + } + .panel() + buttons + } + .padding(24) + } + + private var defeat: some View { + VStack(spacing: 14) { + Text("GAME OVER") + .font(RetroFont.title(34)) + .foregroundStyle(Palette.danger) + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(Flavor.crazyPeteIntro) + Text(Flavor.crazyPeteMonologue) + Text(Flavor.crazyPeteOutro) + .foregroundStyle(Palette.danger) + } + .font(RetroFont.body(13)) + .foregroundStyle(Palette.ink) + .padding(4) + } + .frame(maxHeight: 320) + .panel() + buttons + } + .padding(20) + } + + private var buttons: some View { + HStack(spacing: 12) { + Button("Play Again") { vm.rematch() } + .buttonStyle(RetroButtonStyle(fill: Palette.gold)) + Button("Back to Shore") { onExit() } + .buttonStyle(RetroButtonStyle(fill: Palette.parchment)) + } + } +} diff --git a/LiarsDice/Views/GameTableView.swift b/LiarsDice/Views/GameTableView.swift new file mode 100644 index 0000000..19db652 --- /dev/null +++ b/LiarsDice/Views/GameTableView.swift @@ -0,0 +1,186 @@ +import SwiftUI + +/// The playing table: opponents up top, the running narration and current bid +/// in the middle, your dice and controls at the bottom. A game-over panel +/// slides over everything when the last die is lost. +struct GameTableView: View { + @ObservedObject var vm: GameViewModel + var onExit: () -> Void + + private var revealing: Bool { vm.reveal != nil } + private var highlightFace: Int? { vm.reveal?.bid.face } + + var body: some View { + ZStack { + TableBackground() + + VStack(spacing: 8) { + topBar + opponentsRow + Spacer(minLength: 4) + centerArea + Spacer(minLength: 4) + playerArea + } + .padding(.horizontal, 10) + .padding(.top, 4) + + if let humanWon = vm.gameOverHumanWon { + GameOverView(humanWon: humanWon, vm: vm, onExit: onExit) + .transition(.opacity.combined(with: .scale)) + } + } + .animation(.easeInOut(duration: 0.35), value: vm.gameOverHumanWon) + } + + // MARK: - Top bar + + private var topBar: some View { + HStack { + Button(action: onExit) { + Text("‹ Menu").font(RetroFont.body(13)).foregroundStyle(Palette.cream) + } + Spacer() + Text("\(vm.diceInPlay) dice in play") + .font(RetroFont.body(13)) + .foregroundStyle(Palette.gold) + } + .padding(.horizontal, 4) + } + + // MARK: - Opponents + + private var opponentsRow: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(vm.opponents) { opp in + OpponentView( + player: opp, + isCurrent: vm.state.currentPlayerID == opp.id, + bid: opp.id == vm.state.currentBidderID ? vm.currentBid : nil, + revealed: revealing, + highlightFace: highlightFace, + rollTrigger: vm.rollNonce + ) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 4) + } + } + + // MARK: - Center (narration + bid / reveal) + + private var centerArea: some View { + VStack(spacing: 12) { + if revealing, let reveal = vm.reveal { + revealVisual(reveal) + } else if let bid = vm.currentBid { + currentBidVisual(bid) + } + + Text(vm.headline) + .font(RetroFont.body(15)) + .foregroundStyle(Palette.ink) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .panel(Palette.parchment.opacity(0.95)) + } + .padding(.horizontal, 4) + } + + private func currentBidVisual(_ bid: Bid) -> some View { + VStack(spacing: 4) { + Text("CURRENT BID") + .font(RetroFont.body(12)) + .foregroundStyle(Palette.cream.opacity(0.8)) + HStack(spacing: 8) { + Text("\(bid.quantity)") + .font(RetroFont.title(40)) + .foregroundStyle(Palette.gold) + Text("×").font(RetroFont.title(28)).foregroundStyle(Palette.cream) + DieView(value: bid.face, size: 44) + } + } + } + + private func revealVisual(_ reveal: Reveal) -> some View { + VStack(spacing: 6) { + Text(reveal.bidWasGood ? "THE BID HELD!" : "CAUGHT IN A LIE!") + .font(RetroFont.title(20)) + .foregroundStyle(reveal.bidWasGood ? Palette.gold : Palette.danger) + HStack(spacing: 8) { + DieView(value: reveal.bid.face, size: 40) + Text("× \(reveal.total)") + .font(RetroFont.title(30)) + .foregroundStyle(Palette.cream) + Text("(bid \(reveal.bid.quantity))") + .font(RetroFont.body(14)) + .foregroundStyle(Palette.cream.opacity(0.8)) + } + } + } + + // MARK: - Player area + + private var playerArea: some View { + VStack(spacing: 10) { + yourDice + controls + } + } + + private var yourDice: some View { + VStack(spacing: 4) { + Text("YER DICE") + .font(RetroFont.body(12)) + .foregroundStyle(Palette.gold) + HStack(spacing: 6) { + if let human = vm.human { + ForEach(Array(human.cup.enumerated()), id: \.offset) { _, value in + DieView(value: value, size: 40, rollTrigger: vm.rollNonce) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Palette.gold, lineWidth: 3) + .opacity(revealing && highlightFace == value ? 1 : 0) + ) + } + if human.isEliminated { + Text("Out of dice!").font(RetroFont.body(14)).foregroundStyle(Palette.cream) + } + } + } + } + } + + @ViewBuilder private var controls: some View { + if vm.gameOverHumanWon != nil { + EmptyView() + } else if vm.isHumanOpening && !vm.isBusy { + BidControlView(mode: .opening, diceInPlay: vm.diceInPlay) { bid in + vm.submitOpeningBid(bid) + } + } else if vm.isHumanBidding && !vm.isBusy { + VStack(spacing: 10) { + if vm.canRaise, let current = vm.currentBid { + BidControlView(mode: .raise(current), diceInPlay: vm.diceInPlay) { bid in + vm.raise(bid) + } + } else { + Text("No higher bid be possible — ye must CHALLENGE!") + .font(RetroFont.body(13)) + .foregroundStyle(Palette.cream) + .multilineTextAlignment(.center) + } + Button("CHALLENGE!") { vm.challenge() } + .buttonStyle(RetroButtonStyle(fill: Palette.danger, textColor: Palette.cream)) + } + } else { + Text(revealing ? "Countin' the dice..." : "Watch the table, matey...") + .font(RetroFont.body(14)) + .foregroundStyle(Palette.cream.opacity(0.85)) + .frame(maxWidth: .infinity) + .frame(height: 54) + } + } +} diff --git a/LiarsDice/Views/MainMenuView.swift b/LiarsDice/Views/MainMenuView.swift new file mode 100644 index 0000000..ba0ba2c --- /dev/null +++ b/LiarsDice/Views/MainMenuView.swift @@ -0,0 +1,166 @@ +import SwiftUI + +/// Title screen and pre-game setup: your name, the crew size, difficulty, and +/// the optional "ones are wild" house rule. Hands a finished ``GameRules`` and +/// name back to the caller to start a game. +struct MainMenuView: View { + var onStart: (String, GameRules) -> Void + + @AppStorage("playerName") private var name: String = "" + @AppStorage("difficulty") private var difficultyRaw: String = Difficulty.normal.rawValue + @AppStorage("soundEnabled") private var soundEnabled: Bool = true + @AppStorage("hapticsEnabled") private var hapticsEnabled: Bool = true + @State private var botCount: Int = 3 + @State private var onesWild: Bool = false + @State private var showRules = false + + private var difficulty: Difficulty { + Difficulty(rawValue: difficultyRaw) ?? .normal + } + + var body: some View { + ZStack { + TableBackground() + ScrollView { + VStack(spacing: 22) { + header + settingsPanel + Button("SET SAIL ⚓︎") { + let rules = GameRules( + startingDice: 5, + botCount: botCount, + onesAreWild: onesWild, + difficulty: difficulty + ) + onStart(name.trimmingCharacters(in: .whitespaces), rules) + } + .buttonStyle(RetroButtonStyle(fill: Palette.gold, fontSize: 22)) + + Button { showRules.toggle() } label: { + Text(showRules ? "Hide the rules" : "How d'ye play?") + .font(RetroFont.body(13)) + .foregroundStyle(Palette.cream.opacity(0.85)) + } + if showRules { + Text(Flavor.rules) + .font(RetroFont.body(13)) + .foregroundStyle(Palette.ink) + .panel() + .transition(.opacity) + } + } + .padding(20) + } + } + .animation(.easeInOut(duration: 0.2), value: showRules) + } + + private var header: some View { + VStack(spacing: 6) { + Text("LIAR'S DICE") + .font(RetroFont.title(44)) + .foregroundStyle(Palette.gold) + .shadow(color: Palette.ink, radius: 0, x: 0, y: 3) + Text("☠︎ A Pirate's Bluffing Game ☠︎") + .font(RetroFont.body(14)) + .foregroundStyle(Palette.cream) + Text(Flavor.tagline) + .font(RetroFont.body(12)) + .foregroundStyle(Palette.cream.opacity(0.8)) + .multilineTextAlignment(.center) + .padding(.top, 2) + } + .padding(.top, 10) + } + + private var settingsPanel: some View { + VStack(alignment: .leading, spacing: 16) { + field(title: "YER NAME") { + TextField("Captain No-Beard", text: $name) + .textInputAutocapitalization(.words) + .autocorrectionDisabled() + .font(RetroFont.body(16)) + .foregroundStyle(Palette.ink) + .padding(10) + .background(RoundedRectangle(cornerRadius: 8).fill(Palette.bone)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Palette.ink, lineWidth: 2)) + } + + field(title: "DIFFICULTY") { + HStack(spacing: 8) { + ForEach(Difficulty.allCases, id: \.self) { level in + chip(level.displayName, selected: difficulty == level) { + difficultyRaw = level.rawValue + } + } + } + } + Text(difficulty.blurb) + .font(RetroFont.body(11)) + .foregroundStyle(Palette.ink.opacity(0.7)) + + field(title: "CREW SIZE") { + HStack(spacing: 14) { + roundButton("–") { botCount = max(1, botCount - 1) } + Text("\(botCount) bots") + .font(RetroFont.heavy(18)) + .foregroundStyle(Palette.ink) + .frame(minWidth: 90) + roundButton("+") { botCount = min(5, botCount + 1) } + } + } + + toggleRow("Ones are wild", isOn: $onesWild) + toggleRow("Sound effects", isOn: $soundEnabled) + toggleRow("Haptics", isOn: $hapticsEnabled) + } + .panel() + } + + private func toggleRow(_ title: String, isOn: Binding) -> some View { + Toggle(isOn: isOn) { + Text(title) + .font(RetroFont.body(14)) + .foregroundStyle(Palette.ink) + } + .tint(Palette.danger) + } + + // MARK: - Small building blocks + + @ViewBuilder + private func field(title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(RetroFont.body(12)) + .foregroundStyle(Palette.ink.opacity(0.7)) + content() + } + } + + private func chip(_ label: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(RetroFont.body(12)) + .foregroundStyle(selected ? Palette.ink : Palette.cream) + .padding(.vertical, 8) + .padding(.horizontal, 10) + .frame(maxWidth: .infinity) + .background(RoundedRectangle(cornerRadius: 8).fill(selected ? Palette.gold : Palette.wood)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Palette.ink, lineWidth: 2)) + } + .buttonStyle(.plain) + } + + private func roundButton(_ label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(label) + .font(RetroFont.title(22)) + .foregroundStyle(Palette.cream) + .frame(width: 40, height: 40) + .background(Circle().fill(Palette.wood)) + .overlay(Circle().stroke(Palette.ink, lineWidth: 2)) + } + .buttonStyle(.plain) + } +} diff --git a/LiarsDice/Views/OpponentView.swift b/LiarsDice/Views/OpponentView.swift new file mode 100644 index 0000000..a0f3df3 --- /dev/null +++ b/LiarsDice/Views/OpponentView.swift @@ -0,0 +1,94 @@ +import SwiftUI + +/// One bot's seat at the top of the table: a speech bubble for their bid, their +/// dice (face-down, or revealed during a challenge), and their name. +struct OpponentView: View { + let player: Player + var isCurrent: Bool // it's this bot's turn + var bid: Bid? // their current bid, if they hold the lead + var revealed: Bool // show real dice values (during a reveal) + var highlightFace: Int? // ring the dice that matched the challenged bid + var rollTrigger: Int + + private var dieSize: CGFloat { player.diceCount > 4 ? 18 : 22 } + + var body: some View { + VStack(spacing: 5) { + bubble + .frame(height: 26) + + diceRow + + Text(player.name) + .font(RetroFont.body(11)) + .foregroundStyle(Palette.cream) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .padding(8) + .frame(width: 96) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isCurrent ? Palette.gold.opacity(0.22) : Color.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isCurrent ? Palette.gold : Color.clear, lineWidth: 2) + ) + .opacity(player.isEliminated ? 0.4 : 1) + .overlay(alignment: .center) { + if player.isEliminated { + Text("OUT") + .font(RetroFont.heavy(14)) + .foregroundStyle(Palette.cream) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Capsule().fill(Palette.danger)) + .rotationEffect(.degrees(-12)) + } + } + .animation(.easeInOut(duration: 0.2), value: isCurrent) + } + + @ViewBuilder private var bubble: some View { + if player.isEliminated { + EmptyView() + } else if let bid, !revealed { + HStack(spacing: 3) { + Text("\(bid.quantity)×").font(RetroFont.heavy(12)) + DieView(value: bid.face, size: 16) + } + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Capsule().fill(Palette.parchment)) + .overlay(Capsule().stroke(Palette.ink, lineWidth: 2)) + .foregroundStyle(Palette.ink) + } else if isCurrent && !revealed { + Text("…thinkin'") + .font(RetroFont.body(11)) + .foregroundStyle(Palette.ink) + .padding(.horizontal, 8).padding(.vertical, 3) + .background(Capsule().fill(Palette.parchment.opacity(0.85))) + } else { + EmptyView() + } + } + + @ViewBuilder private var diceRow: some View { + HStack(spacing: 3) { + if revealed { + ForEach(Array(player.cup.enumerated()), id: \.offset) { _, value in + DieView(value: value, size: dieSize, rollTrigger: rollTrigger) + .overlay( + RoundedRectangle(cornerRadius: dieSize * 0.2) + .stroke(Palette.gold, lineWidth: 3) + .opacity(highlightFace == value ? 1 : 0) + ) + } + } else { + ForEach(Array(0.. This project began life as a heavily-commented Python CLI used to teach high +> school students to code. It is now being rebuilt as a native iOS game for the +> App Store. The original teaching code is preserved under +> [`python-legacy/`](python-legacy/). -## How to run it - -You need Python 3 installed. Then, from this folder: +## Project layout ``` -python liars_dice.py +LiarsDice.xcodeproj The iOS app project — open this in Xcode +LiarsDice/ + App/ App entry point and root navigation + Engine/ Pure game logic (no UI) — the rules live here + ViewModels/ GameViewModel: drives the engine, paces bot turns + Views/ SwiftUI screens (menu, table, dice, reveal, game over) + Theme/ Retro "pirate tavern" colors, fonts, components + Services/ Feedback: sound effects + haptics + Resources/ Flavor text (Crazy Pete ending) + Audio/ chiptune SFX + Assets.xcassets/ App icon + accent color +Package.swift Builds/tests the Engine from the command line +Tests/ Engine unit tests (swift test, or run in Xcode) +project.yml XcodeGen spec (recovery path; see Makefile) +tools/ Asset generators (make_icon.py, make_sfx.py — stdlib only) +python-legacy/ The original Python teaching game, kept for posterity +docs/ROADMAP.md The plan to ship this on the App Store +docs/BACKLOG.md Prioritized feature backlog & user stories +docs/ART_DIRECTION.md Look-and-feel / pixel-art style guide ``` -(On some computers the command is `python3` instead of `python`.) +The engine is deliberately separated from the UI: `LiarsDice/Engine` is pure +value-type Swift with no SwiftUI imports. It compiles into the app **and** into +a standalone Swift package so the rules can be unit-tested in seconds without a +simulator. + +## Getting started (on a Mac with Xcode 16+) + +```bash +# 1. Run the engine tests — no Xcode UI required +swift test # or: make test + +# 2. Open and run the app +open LiarsDice.xcodeproj # or: make open +# then pick an iPhone simulator and press ⌘R +``` + +Before building to a device or submitting, set your signing team and a real +bundle identifier in Xcode (**Target ▸ Signing & Capabilities**). The project +ships with a placeholder bundle id `com.example.liarsdice`. + +If the committed Xcode project ever drifts, regenerate it from `project.yml`: + +```bash +brew install xcodegen && xcodegen generate # or: make project +``` ## How to play -1. Everyone secretly rolls their dice. You can see yours; the bots hide theirs. +1. Everyone secretly rolls their dice. You see yours; the crew hides theirs. 2. On your turn you either: - **RAISE** the bid — claim there are *more* dice of a value, **or** the same - number of a *higher* value, across everyone's cups, or + number of a *higher* value, across everyone's cups; or - **CHALLENGE** the last bid if you think it's a lie. -3. When a bid is challenged, everyone reveals their dice and we count them up: - - If there really are at least that many, the bid was good and the - **challenger** loses a die. - - If there aren't enough, the bid was a lie and the **bidder** loses a die. -4. Lose all your dice and you're out. The last pirate standing wins! - -## Files - -- `liars_dice.py` — the complete, playable game. -- `learning_liars_dice.py` — the original "thinking out loud" notes showing how - the project was figured out, step by step. Great for learners to read! -- `init.py` — a tiny package file with a version number and a greeting. +3. On a challenge, all dice are counted. Guess wrong and you lose a die. +4. Lose all your dice and you're out. Last pirate standing wins the gold. + +House rules (set on the main menu): crew size, bot difficulty, and an optional +"ones are wild" variant. + +## Status + +**Done — v0.1.0 "Playable foundation"** (see [`CHANGELOG.md`](CHANGELOG.md)): +a complete rules engine, single-player vs. bots with three difficulties, the +full game loop, a styled retro UI, the app icon, and chiptune sound + haptics. + +**In progress / next** (see [`docs/BACKLOG.md`](docs/BACKLOG.md) for the +prioritized backlog and [`docs/ROADMAP.md`](docs/ROADMAP.md) for the path to the +App Store): a first-run tutorial, VoiceOver accessibility, App Store Connect +setup, and art/animation polish. The agreed visual direction is in +[`docs/ART_DIRECTION.md`](docs/ART_DIRECTION.md). diff --git a/Tests/LiarsDiceEngineTests/GameEngineTests.swift b/Tests/LiarsDiceEngineTests/GameEngineTests.swift new file mode 100644 index 0000000..9e4b3ff --- /dev/null +++ b/Tests/LiarsDiceEngineTests/GameEngineTests.swift @@ -0,0 +1,240 @@ +import XCTest +import LiarsDiceEngine + +final class GameEngineTests: XCTestCase { + + // MARK: - Bid ordering + + func testBidIsHigher() { + let base = Bid(quantity: 3, face: 4) + XCTAssertTrue(Bid(quantity: 4, face: 1).isHigher(than: base)) // more dice wins + XCTAssertTrue(Bid(quantity: 3, face: 5).isHigher(than: base)) // same qty, higher face + XCTAssertFalse(Bid(quantity: 3, face: 4).isHigher(than: base)) // equal is not higher + XCTAssertFalse(Bid(quantity: 3, face: 3).isHigher(than: base)) // lower face + XCTAssertFalse(Bid(quantity: 2, face: 6).isHigher(than: base)) // fewer dice + } + + // MARK: - New game setup + + func testNewGameSetup() { + var rng = SeededRandomNumberGenerator(seed: 1) + let state = GameState.newGame( + playerName: "Tester", + rules: GameRules(startingDice: 5, botCount: 3), + using: &rng + ) + XCTAssertEqual(state.players.count, 4) + XCTAssertEqual(state.opponents.count, 3) + XCTAssertEqual(state.humanPlayer?.name, "Tester") + XCTAssertEqual(state.diceInPlay, 20) + XCTAssertEqual(state.round, 1) + XCTAssertTrue(state.players.allSatisfy { $0.cup.count == 5 }) + if case .opening = state.phase {} else { + XCTFail("A fresh game should be waiting on an opening bid") + } + } + + func testEmptyNameFallsBack() { + var rng = SeededRandomNumberGenerator(seed: 7) + let state = GameState.newGame(playerName: "", using: &rng) + XCTAssertEqual(state.humanPlayer?.name, "You") + } + + // MARK: - Challenge resolution + + /// Builds a 2-player bidding state with fixed cups so outcomes are exact. + private func biddingState( + humanCup: [Int], + botCup: [Int], + bid: Bid, + bidderIsBot: Bool, + onesWild: Bool = false + ) -> (state: GameState, humanID: UUID, botID: UUID) { + let human = Player(name: "You", isBot: false, diceCount: humanCup.count, cup: humanCup) + let bot = Player(name: "Bot", isBot: true, diceCount: botCup.count, cup: botCup) + // The current player (challenger) is whoever is NOT the bidder. + let bidderID = bidderIsBot ? bot.id : human.id + let challengerID = bidderIsBot ? human.id : bot.id + let state = GameState( + players: [human, bot], + rules: GameRules(startingDice: max(humanCup.count, botCup.count), + botCount: 1, onesAreWild: onesWild), + phase: .bidding(current: challengerID), + currentBid: bid, + currentBidderID: bidderID, + round: 1 + ) + return (state, human.id, bot.id) + } + + func testChallengeWhenBidIsGood() { + // Three 3s on the table (2 + 1); bid of three 3s is exactly met. + var (state, humanID, botID) = biddingState( + humanCup: [3, 3], + botCup: [3, 5], + bid: Bid(quantity: 3, face: 3), + bidderIsBot: true + ) + let events = state.challenge() + let reveal = state.currentReveal + XCTAssertEqual(reveal?.total, 3) + XCTAssertEqual(reveal?.bidWasGood, true) + // Bid was good, so the challenger (the human) loses. + XCTAssertEqual(reveal?.loserID, humanID) + XCTAssertNotEqual(reveal?.loserID, botID) + XCTAssertTrue(events.contains(.revealed(reveal!))) + } + + func testChallengeWhenBidIsALie() { + // Only two 5s exist, but the bot bid three 5s. The bluff is caught. + var (state, _, botID) = biddingState( + humanCup: [5, 2], + botCup: [5, 1], + bid: Bid(quantity: 3, face: 5), + bidderIsBot: true + ) + _ = state.challenge() + XCTAssertEqual(state.currentReveal?.total, 2) + XCTAssertEqual(state.currentReveal?.bidWasGood, false) + XCTAssertEqual(state.currentReveal?.loserID, botID) // bidder caught lying + } + + func testOnesAreWildCounting() { + // Two natural 4s plus two wild 1s = four 4s when the rule is on. + var (state, _, _) = biddingState( + humanCup: [4, 1], + botCup: [4, 1], + bid: Bid(quantity: 4, face: 4), + bidderIsBot: true, + onesWild: true + ) + _ = state.challenge() + XCTAssertEqual(state.currentReveal?.total, 4) + XCTAssertEqual(state.currentReveal?.bidWasGood, true) + } + + // MARK: - Resolving a reveal + + func testResolveRevealLosesADieAndStartsNextRound() { + var (state, humanID, _) = biddingState( + humanCup: [3, 3], + botCup: [3, 5], + bid: Bid(quantity: 3, face: 3), + bidderIsBot: true + ) + _ = state.challenge() // human (challenger) will lose + var rng = SeededRandomNumberGenerator(seed: 99) + _ = state.resolveReveal(using: &rng) + + XCTAssertEqual(state.player(humanID)?.diceCount, 1) // 2 -> 1 + XCTAssertEqual(state.round, 2) + // Loser starts the next round. + if case .opening(let starter) = state.phase { + XCTAssertEqual(starter, humanID) + } else { + XCTFail("Expected the next round to be waiting on an opening bid") + } + } + + func testHumanEliminationEndsGame() { + // Human has a single die and will lose the challenge. + let human = Player(name: "You", isBot: false, diceCount: 1, cup: [2]) + let bot = Player(name: "Bot", isBot: true, diceCount: 2, cup: [6, 6]) + var state = GameState( + players: [human, bot], + rules: GameRules(startingDice: 2, botCount: 1), + phase: .bidding(current: human.id), // human challenges + currentBid: Bid(quantity: 2, face: 6), + currentBidderID: bot.id, + round: 1 + ) + _ = state.challenge() // two 6s exist, bid good -> human loses + var rng = SeededRandomNumberGenerator(seed: 3) + _ = state.resolveReveal(using: &rng) + XCTAssertEqual(state.phase, .gameOver(humanWon: false)) + } + + func testLastPlayerStandingWins() { + // Bot has one die and will lose; only the human remains afterward. + let human = Player(name: "You", isBot: false, diceCount: 2, cup: [1, 1]) + let bot = Player(name: "Bot", isBot: true, diceCount: 1, cup: [2]) + var state = GameState( + players: [human, bot], + rules: GameRules(startingDice: 2, botCount: 1), + phase: .bidding(current: human.id), // human challenges a lie + currentBid: Bid(quantity: 3, face: 6), + currentBidderID: bot.id, + round: 1 + ) + _ = state.challenge() // zero 6s -> bid was a lie -> bot loses its last die + var rng = SeededRandomNumberGenerator(seed: 5) + _ = state.resolveReveal(using: &rng) + XCTAssertEqual(state.phase, .gameOver(humanWon: true)) + } + + // MARK: - Bid helpers + + func testMinimumRaiseAndLegalRaise() { + let human = Player(name: "You", isBot: false, diceCount: 3, cup: [1, 2, 3]) + let bot = Player(name: "Bot", isBot: true, diceCount: 3, cup: [4, 5, 6]) + var state = GameState( + players: [human, bot], + rules: GameRules(startingDice: 3, botCount: 1), + phase: .bidding(current: human.id), + currentBid: Bid(quantity: 2, face: 4), + currentBidderID: bot.id, + round: 1 + ) + XCTAssertTrue(state.legalRaiseExists()) + XCTAssertEqual(state.minimumRaise(), Bid(quantity: 2, face: 5)) + + // Bump to the ceiling: every one of the 6 dice showing a 6. + _ = state.raise(Bid(quantity: 6, face: 6)) + XCTAssertFalse(state.legalRaiseExists()) + XCTAssertNil(state.minimumRaise()) + } + + func testNextActivePlayerSkipsEliminated() { + let a = Player(name: "A", isBot: false, diceCount: 2) + let b = Player(name: "B", isBot: true, diceCount: 0) // out + let c = Player(name: "C", isBot: true, diceCount: 2) + let state = GameState( + players: [a, b, c], + rules: GameRules(startingDice: 2, botCount: 2), + phase: .bidding(current: a.id), + currentBid: Bid(quantity: 1, face: 1), + currentBidderID: a.id, + round: 1 + ) + XCTAssertEqual(state.nextActivePlayerID(after: a.id), c.id) // skips eliminated B + } + + // MARK: - Full game integration + + func testFullGameTerminates() { + // Drive every seat with the bot AI and make sure a seeded game always + // reaches a winner without stalling. + var rng = SeededRandomNumberGenerator(seed: 20260617) + var state = GameState.newGame( + playerName: "Sim", + rules: GameRules(startingDice: 5, botCount: 3), + using: &rng + ) + var safety = 0 + while !state.isGameOver && safety < 100_000 { + safety += 1 + switch state.phase { + case .opening, .bidding: + let action = BotAI.action(for: state, using: &rng) + _ = state.apply(action) + case .reveal: + _ = state.resolveReveal(using: &rng) + case .gameOver: + break + } + } + XCTAssertTrue(state.isGameOver) + XCTAssertLessThan(safety, 100_000) + XCTAssertEqual(state.activePlayers.count, 1) + } +} diff --git a/docs/ART_DIRECTION.md b/docs/ART_DIRECTION.md new file mode 100644 index 0000000..db08ea0 --- /dev/null +++ b/docs/ART_DIRECTION.md @@ -0,0 +1,139 @@ +# Art direction & style guide — Liar's Dice + +This is the decided look and feel. Use it to source pixel art (itch.io, asset +packs, commissions) or to make your own. Everything here matches the in-app code +(`LiarsDice/Theme/Theme.swift`) and the shipped app icon, so new art should drop +in consistently. + +## 1. Vision + +**"A pirate's tavern dice game — cozy lantern-light with a hint of menace."** + +- **Genre cue:** retro / 16-bit pixel art, but readable and clean (not noisy). +- **Mood:** warm wood and felt, weathered gold, bone-white dice, deep sea-night + background. Playful pirate humor (kept from the original), with the lurking + threat of the Flying Dutchman on a loss. +- **Reference touchstones:** classic tabletop dice, *Inside the Backrooms*-free + pixel UI cleanliness, *Loop Hero* / *Shipwrecked 64* palettes, old wooden + board games. Cozy, hand-made, slightly grimy — not slick or corporate. + +## 2. Palette (authoritative) + +Use these exact hex values; they are defined in `Palette` in `Theme.swift`. + +| Token | Hex | Use | +|---|---|---| +| Night | `#14151F` | Background base, deepest shadow | +| Night Dark | `#0C0D14` | Vignette / icon corners | +| Table | `#2A6F5A` | Felt green (table surface) | +| Table Hi | `#37866E` | Felt highlight / gradient center | +| Table Dark | `#1E5142` | Felt shadow | +| Wood | `#4A3526` | Cup backs, frames, knobs | +| Wood Dark | `#2A1C12` | Wood shadow / outer frame | +| Parchment | `#E9D8A6` | Panels, speech bubbles, bones | +| Parchment Dk | `#8A7344` | Bone/parchment shading | +| Gold | `#F2C14E` | Accents, frames, highlights, primary buttons | +| Gold Dark | `#B8882A` | Gold shading / borders | +| Bone | `#F6F0E2` | Dice body | +| Bone Hi | `#FFFCF2` | Dice top highlight | +| Bone Shade | `#CEC4B0` | Dice underside | +| Pip | `#23201A` | Dice pips | +| Ink | `#241B12` | Outlines (use on almost everything) | +| Cream | `#F7F3E8` | Light text on dark | +| Danger | `#D1495B` | Challenge button, "lie", destructive | +| Raise | `#3D8BFF` | (reserved) raise accents | + +Rules of thumb: **dark ink outlines** on shapes; **gold** is the hero accent +(use sparingly); **danger red** only for challenge/lie moments. + +## 3. Pixel grid & scaling + +- **Author at low resolution, scale by integers.** The app icon is built on a + **64×64** grid, upscaled ×16 to 1024. Sprites should follow the same idea. +- **No anti-aliasing / no smoothing.** Use nearest-neighbor scaling. In SwiftUI, + set `.interpolation(.none)` on `Image` for any imported pixel sprite. +- **Recommended sprite sizes (author size → in-app):** + | Asset | Author px | Notes | + |---|---|---| + | Die face (1–6) | 16×16 or 24×24 | One sprite per face, plus a "cup back" | + | Opponent portrait | 32×32 | Optional pirate avatars | + | Icon / button glyphs | 16×16 | Sound, back, settings, skull | + | Table tile / texture | 32×32 (tiling) | Subtle felt/wood grain | +- **Outline weight:** 1 author-pixel, color `Ink (#241B12)`. + +## 4. Dice (the hero asset) + +- **Body:** Bone `#F6F0E2`, rounded-square, 1px Ink outline. Top edge gets a + Bone Hi band; underside a Bone Shade band (carved look). +- **Pips:** `#23201A`, round-ish, classic layouts (see `PipLayout` in + `DieView.swift`). 5-pip face is the brand face (used in the icon). +- **Cup back (hidden die):** Wood `#4A3526` with a small parchment dot, Ink + outline (see `FaceDownDie`). +- Currently drawn procedurally in SwiftUI. To swap in hand-drawn sprites, replace + the body of `DieView`/`FaceDownDie` with `Image(...).interpolation(.none)` — + the call sites don't change. + +## 5. UI components + +- **Panels:** Parchment `#E9D8A6`, 12px corner radius, 3px Ink border + (`.panel()` modifier). +- **Primary button:** Gold fill, Ink border, drops 3px when pressed + (`RetroButtonStyle`). **Challenge button:** Danger red fill, Cream text. +- **Speech bubbles:** Parchment capsule, Ink border, holds a bid like `3× [die]`. +- **Frames:** thick Wood Dark outer frame; Gold inner frame for emphasis (as in + the icon). + +## 6. Typography + +- **Now:** system monospaced, heavy/black weights (`RetroFont`) — reads as retro, + ships today, scales with Dynamic Type. +- **Upgrade path (P2):** bundle a true pixel font and change `RetroFont` in one + place. Good options & licenses: + - **Press Start 2P** (OFL, free) — iconic arcade look, wide. + - **m6x11 / m5x7** by Daniel Linssen (free, very legible). + - **Pixellari**, **Munro**, **ThaleahFat** (free/cheap, friendly). + - Verify each font's license allows app embedding before shipping. + +## 7. App icon + +Shipped: a bone die (5 pips) with crossed bones behind it, a gold pixel frame, +on a teal→navy radial. Generated reproducibly by `tools/make_icon.py`. Concept +variants live in `docs/icon-concepts/` (bones / clean / gold die). To change it, +edit and re-run the script — it writes `AppIcon-1024.png` directly into the +asset catalog (opaque, 1024×1024, App-Store compliant). + +## 8. Audio (for consistency with art) + +8-bit chiptune, generated by `tools/make_sfx.py` (square/triangle/noise waves). +Keep any future audio in the same lo-fi register: short, punchy, no realistic +foley. Files live in `LiarsDice/Resources/Audio/`. + +## 9. Animation + +- **Dice:** integer-feeling tumbles (the current `rotation3DEffect` spin); next + step is cycling random faces before settling. +- **Springs over eases** for tactile feel; keep durations short (0.3–0.6s). +- Always provide a **reduce-motion** fallback (static dice) — see Backlog Epic D. + +## 10. Asset shopping / production list + +If commissioning or sourcing, this is the wishlist (priority order): +1. **Die faces 1–6 + cup back** (16/24px) — the only assets that meaningfully + change the feel; everything else is already coded. +2. **6–8 pirate opponent portraits** (32px) — gives the table personality. +3. **App-icon glyph set** (16px): sound on/off, back, settings, skull, coin. +4. **Table & wood textures** (32px tiling) — subtle, low-contrast. +5. **Win/lose flourishes** (coins, skull, sparkles). + +**Where to look:** itch.io (search "pixel dice", "pirate pixel UI"), Kenney.nl +(CC0), OpenGameArt, CraftPix. **Tools to make your own:** Aseprite (paid, the +standard), Piskel (free, web), LibreSprite (free). **Licensing:** prefer CC0 / +OFL / "commercial use OK"; keep a `CREDITS.md` for anything attribution-required. + +## 11. Do / Don't + +- ✅ Hard 1px Ink outlines, flat fills, integer scaling, limited palette. +- ✅ Gold as a rare accent; red only for danger/lie. +- ❌ No gradients-within-sprites, drop-shadow blur, or anti-aliased edges. +- ❌ No realistic casino/slot-machine imagery (keeps the age rating low). +- ❌ Don't drift the palette — sample from the table above. diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md new file mode 100644 index 0000000..04591a1 --- /dev/null +++ b/docs/BACKLOG.md @@ -0,0 +1,132 @@ +# Product backlog — Liar's Dice + +A prioritized backlog of features and user stories on the road to (and past) an +App Store launch. Stories use the form **"As a … I want … so that …"** with +acceptance criteria where it helps. + +**Priority:** `P0` = required for a credible launch · `P1` = strong first update · +`P2` = growth / nice-to-have · `P3` = icebox. + +**Status:** ✅ done · 🔶 partial · ⬜ not started. + +--- + +## Milestone 1.0 — App Store launch (MVP) + +### Epic A — Core gameplay ✅ (foundation complete) +- ✅ `P0` As a player, I want to play full rounds of Liar's Dice vs. bots so that + I can enjoy the game solo. *(engine + loop done)* +- ✅ `P0` As a player, I want to raise or challenge on my turn so that I can bluff + and call bluffs. *(done)* +- ✅ `P0` As a player, I want a reveal that counts all dice so that outcomes are + clear and fair. *(done)* +- ✅ `P0` As a player, I want bots with adjustable difficulty so that the game + scales to my skill. *(Cabin Boy / Buccaneer / Sea Devil)* +- 🔶 `P0` As a player, I want the game to feel alive (sound, haptics, dice + animation) so that it's satisfying to play. *(audio + haptics + tumble done; + more animation polish in 1.1)* + +### Epic B — First-run & onboarding +- ⬜ `P0` As a new player, I want a short interactive tutorial (or a clear rules + card on first launch) so that I can learn without reading a wall of text. + - **AC:** First launch shows a 3–4 step walkthrough or a dismissible rules + overlay; never blocks returning players; reachable later from the menu. +- ✅ `P0` As a player, I want to set my name so that the game feels personal. +- ⬜ `P1` As a player, I want a "how to play" entry in-game (not just the menu) so + that I can review rules mid-session. + +### Epic C — Visual identity & art +- ✅ `P0` As a user browsing the App Store, I want a distinctive app icon so that + the game looks legit. *(pixel-art bone-die icon shipped)* +- ✅ `P0` As a player, I want a consistent retro look so that the game feels + designed. *(Theme + ART_DIRECTION.md)* +- ⬜ `P1` As a player, I want characterful opponents (pirate avatars) so that the + table feels populated. +- ⬜ `P2` As a player, I want a real pixel font so that text matches the art. + +### Epic D — Settings & accessibility +- ✅ `P0` As a player, I want to toggle sound and haptics so that I can play + quietly. *(done)* +- ⬜ `P0` As a player using VoiceOver, I want dice, bids, and buttons labelled so + that the game is accessible. + - **AC:** All interactive controls have accessibility labels; the current bid + and your dice are announced; passes an Accessibility Inspector audit. +- ⬜ `P1` As a player, I want Dynamic Type / larger text support so that I can + read comfortably. +- ⬜ `P1` As a player who prefers calm UI, I want a "reduce motion" path so that + dice don't tumble if I disable animation. + +### Epic E — App Store readiness +- ⬜ `P0` Replace the placeholder bundle id and set the signing team. +- ⬜ `P0` Capture screenshots for required device sizes (6.7" + 6.5"). +- ⬜ `P0` Write App Store metadata (name, subtitle, description, keywords). +- ⬜ `P0` Complete the App Privacy questionnaire ("Data Not Collected") and host a + one-page privacy policy. +- ⬜ `P0` Answer the age-rating questionnaire (expected 12+, simulated gambling: + none). +- ⬜ `P0` TestFlight build verified on a real device, then submit for review. + +--- + +## Milestone 1.1 — First update (retention & polish) + +### Epic F — Juice & animation 🔶 +- ⬜ `P1` As a player, I want dice to tumble through random faces before settling + so that rolls feel real. +- ⬜ `P1` As a player, I want the cups to lift and dice to pop on the reveal so + that the climax lands. +- ⬜ `P2` As a player, I want coins/confetti on a win so that victory feels + rewarding. +- ⬜ `P1` As a player, I want a quick roll-off animation to decide who starts so + that the opener feels earned (a nod to the original). + +### Epic G — Progression & stats +- ⬜ `P1` As a returning player, I want my stats (games, wins, bluffs caught) saved + so that I can track improvement. + - **AC:** Stats persist across launches (SwiftData/`@AppStorage`); a simple + stats screen shows totals and a win streak. +- ⬜ `P2` As a player, I want achievements so that I have goals to chase. + +### Epic H — AI depth +- ⬜ `P1` As a skilled player, I want a smarter "Sea Devil" that tracks bid history + and models opponents so that hard mode is genuinely hard. +- ⬜ `P2` As a player, I want bots with distinct personalities (cautious, reckless, + bluffer) so that opponents feel individual. + +--- + +## Milestone 1.2+ — Growth + +### Epic I — Game modes +- ⬜ `P1` As a player, I want a **Daily Challenge** (seeded dice, same for + everyone, shareable result) so that I have a reason to return daily. + - **AC:** Uses `SeededRandomNumberGenerator` keyed to the date; result is + shareable as text/image. +- ⬜ `P2` As friends in a room, I want **pass-and-play** local multiplayer so that + we can play on one device. +- ⬜ `P3` As a player, I want **online multiplayer** (Game Center) so that I can + play remotely. *(large effort; validate demand first)* + +### Epic J — Monetization +- ⬜ `P1` As a maker, I want a single tip-jar IAP (StoreKit 2) or a $1.99 price so + that the game can earn while keeping the privacy story clean. +- ⬜ `P2` As a player, I want cosmetic dice skins / table themes (IAP) so that I + can customize; as a maker this is the best long-term revenue. +- ⬜ `P3` As a maker, I want rewarded ads as an alternative to IAP. *(adds privacy + + review overhead; only if pursuing free-with-ads.)* + +### Epic K — Reach & quality +- ⬜ `P2` As a non-English player, I want localization so that I can play in my + language. *(strings are already centralized in Flavor.swift.)* +- ⬜ `P2` As a maker, I want lightweight, privacy-respecting analytics + crash + reporting so that I can fix issues. *(weigh against "Data Not Collected".)* +- ⬜ `P2` As an iPad player, I want a tuned landscape/iPad layout so that the game + looks great on a larger screen. + +--- + +## Notes +- Keep **"Data Not Collected"** true as long as possible — it makes review and the + privacy label trivial. Adding ads or analytics changes that; decide deliberately. +- Bluffing ≠ real-money gambling. Avoid casino chrome (slot reels, real currency) + to keep the age rating low and review smooth. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..0f979e8 --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,113 @@ +# Roadmap: from teaching script to App Store game + +This is the honest, end-to-end plan for turning Liar's Dice into a shippable +iOS game. It's split into what's **done**, what's **next**, and the **store +mechanics** that only you (with the Mac + Apple Developer account) can do. + +--- + +## ✅ Phase 0 — Foundation (this commit) + +- [x] Pivot the repo from a Python CLI to a native **Swift / SwiftUI** app. +- [x] **Pure rules engine** (`LiarsDice/Engine`): bids, raises, challenges, + reveal/tally, dice loss, elimination, win/lose — all deterministic and + unit-tested via `swift test`. +- [x] **Bot AI** with three difficulty levels, ported and tightened from the + original Python bots. +- [x] **Game loop UI**: main menu with settings, the table with opponents and + your cup, bidding controls, the dramatic reveal, and win/lose screens. +- [x] Preserved the game's personality — pirate narration and the legendary + **Crazy Pete** defeat sequence. +- [x] A buildable `LiarsDice.xcodeproj` (plus an XcodeGen recovery spec). + +## 🎨 Phase 1 — Make it look and feel finished + +- [x] **App icon** (1024×1024). Shipped: a pixel-art bone die with crossed bones, + generated reproducibly by `tools/make_icon.py` and wired into the asset + catalog. Concept variants in `docs/icon-concepts/`. Replace any time by + editing/re-running the script. +- [x] **Sound & haptics**: chiptune SFX (roll, bid, challenge, good/lie, win, + lose) synthesized by `tools/make_sfx.py`, plus UIKit haptics — wired + through `Feedback`/`AudioManager` and toggleable in the menu. +- [ ] **Pixel-art dice & table sprites** (optional upgrade). The dice are + currently drawn with SwiftUI shapes, which looks clean and scales + perfectly; swapping in hand-drawn sprites is a localized change in + `DieView`/`OpponentView`. See `docs/ART_DIRECTION.md`. +- [ ] **A real pixel font** (e.g. *Press Start 2P*). Bundle the `.ttf`, register + it, and update `RetroFont` — one place, the whole app changes. +- [ ] **Animation polish**: dice actually tumbling through random faces, cups + lifting on the reveal, coins on a win. + +See **`docs/BACKLOG.md`** for the full prioritized feature backlog and +**`docs/ART_DIRECTION.md`** for the agreed look-and-feel / asset specs. + +## 🕹️ Phase 2 — Depth & retention + +- [ ] Roll-off animation to decide who starts (a nod to the original). +- [ ] Stats & streaks (games won, bluffs caught) via `@AppStorage`/SwiftData. +- [ ] **Daily challenge** using the existing `SeededRandomNumberGenerator` — same + dice for everyone, shareable result. +- [ ] Difficulty tuning and a smarter "Sea Devil" AI (track bid history, model + opponents). +- [ ] Accessibility: VoiceOver labels for dice/bids, Dynamic Type, reduce-motion. +- [ ] Optional: local **pass-and-play** multiplayer, then Game Center online. + +## 🚀 Phase 3 — Ship it (your turn — needs the Mac + Apple Developer account) + +These steps require your Apple Developer account ($99/yr) and signing identity. + +1. **Bundle identifier & signing** + - In Xcode ▸ target **LiarsDice** ▸ *Signing & Capabilities*: pick your Team, + set a unique bundle id (replace `com.example.liarsdice`, e.g. + `com..liarsdice`). +2. **App Store Connect** + - Create the app record at with that + bundle id, a name (check availability), and primary category **Games → + Board** (or **Casino/Card**). +3. **Privacy** + - This app collects **no data** and has no network calls or third-party SDKs, + so the *App Privacy* questionnaire is "Data Not Collected" and a Privacy + Manifest is trivial. (Keep it that way for the easiest review.) + - Liar's Dice involves bluffing, not real-money gambling → expect a **12+** + age rating ("Simulated Gambling: None"; "Infrequent/Mild" only if you add + casino theming). Answer the rating questionnaire honestly. +4. **Marketing assets** + - Screenshots for required device sizes (6.7" and 6.5" iPhone at minimum) — + capture from the simulator. + - App description, keywords, support URL, and a privacy-policy URL (a simple + hosted page stating "no data collected" suffices). +5. **Monetization** (decide early; see below) + - Set price (free or paid) or add In-App Purchases / ads. +6. **TestFlight** + - Archive (*Product ▸ Archive*), upload, and test on real devices / invite + friends before submitting. +7. **Submit for review** + - Submit the build, respond to any reviewer questions, then release. + +## 💰 Monetization options (pick one to aim for) + +| Model | Effort | Notes | +|---|---|---| +| **Paid up front** (e.g. $1.99) | Lowest | Simplest; no SDKs, cleanest privacy. | +| **Free + "Remove distractions / tip jar" IAP** | Low | One non-consumable via StoreKit 2. | +| **Free + rewarded/banner ads** | Medium | Adds an ad SDK → privacy + review overhead. | +| **Free + cosmetic IAP** (dice skins, themes) | Higher | Best long-term if the game has legs. | + +Recommendation: launch **free with a single optional "tip jar" IAP**, or paid at +$1.99 — both keep the privacy story clean and review fast. Add cosmetic IAPs in +a later update once you see retention. + +## ⚠️ Things to know + +- A 1024×1024 **app icon is now included** (generated by `tools/make_icon.py`). + Swap in your own art any time — just keep it 1024×1024, opaque, square. +- Replace the **placeholder bundle id** before archiving. +- Apple review for a simple, offline game is usually quick, but budget a few + days and be ready to answer the gambling/age-rating questions. +- **Continuous integration** isn't set up: GitHub-hosted runners (macOS *and* + Linux) failed to provision for this repo — every job died at startup with no + runner assigned, which points to Actions being disabled or limited on the + account, not a code problem. To enable CI later, turn on GitHub-hosted runners + (or add a self-hosted one) under the repo/owner **Settings → Actions**, then a + workflow can run `swift test` (engine) and `xcodebuild` (the app). Until then, + verify locally with `swift test` and an Xcode build. diff --git a/docs/icon-concepts/concept-bones.png b/docs/icon-concepts/concept-bones.png new file mode 100644 index 0000000..cc1180a Binary files /dev/null and b/docs/icon-concepts/concept-bones.png differ diff --git a/docs/icon-concepts/concept-clean.png b/docs/icon-concepts/concept-clean.png new file mode 100644 index 0000000..12a5dea Binary files /dev/null and b/docs/icon-concepts/concept-clean.png differ diff --git a/docs/icon-concepts/concept-gold.png b/docs/icon-concepts/concept-gold.png new file mode 100644 index 0000000..fec9b3e Binary files /dev/null and b/docs/icon-concepts/concept-gold.png differ diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..baf8ec4 --- /dev/null +++ b/project.yml @@ -0,0 +1,39 @@ +# XcodeGen spec — a recovery / regeneration path for the Xcode project. +# +# The repo already ships a ready-to-open `LiarsDice.xcodeproj`. You only need +# this if that project ever gets out of sync or you'd rather generate it from +# a clean text spec: +# +# brew install xcodegen +# xcodegen generate +# +# It produces a project equivalent to the committed one. +name: LiarsDice +options: + bundleIdPrefix: com.example + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true + groupSortPosition: top +settings: + base: + SWIFT_VERSION: "5.0" + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" +targets: + LiarsDice: + type: application + platform: iOS + sources: + - path: LiarsDice + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.example.liarsdice + GENERATE_INFOPLIST_FILE: YES + ENABLE_PREVIEWS: YES + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor + TARGETED_DEVICE_FAMILY: "1,2" + INFOPLIST_KEY_UILaunchScreen_Generation: YES + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad: "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown" diff --git a/python-legacy/README.md b/python-legacy/README.md new file mode 100644 index 0000000..6314146 --- /dev/null +++ b/python-legacy/README.md @@ -0,0 +1,22 @@ +# Python legacy (archived) + +This folder holds the **original Python command-line version** of Liar's Dice — +the heavily-commented teaching project this repo started as. It's kept for +posterity and because the game's personality (the pirate voice and the +legendary "Crazy Pete" ending) was carried over from here into the iOS app. + +It is no longer the active project. The game is now a native iOS app in +Swift/SwiftUI at the repository root — see the top-level `README.md`. + +## Files + +- `liars_dice.py` — the complete, playable original game. +- `learning_liars_dice.py` — the "thinking out loud" notes showing how the + project was figured out step by step. Great for learners to read. +- `init.py` — a tiny package file with a version number and a greeting. + +## Run it + +``` +python3 liars_dice.py +``` diff --git a/init.py b/python-legacy/init.py similarity index 100% rename from init.py rename to python-legacy/init.py diff --git a/learning_liars_dice.py b/python-legacy/learning_liars_dice.py similarity index 100% rename from learning_liars_dice.py rename to python-legacy/learning_liars_dice.py diff --git a/liars_dice.py b/python-legacy/liars_dice.py similarity index 100% rename from liars_dice.py rename to python-legacy/liars_dice.py diff --git a/tools/make_icon.py b/tools/make_icon.py new file mode 100644 index 0000000..cb4a984 --- /dev/null +++ b/tools/make_icon.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Generate the Liar's Dice app icon as crisp pixel art, using only the Python +standard library (zlib for PNG encoding). No Pillow / external deps required. + +The icon is composed on a small logical grid (64x64) and nearest-neighbor +upscaled to 1024x1024, which keeps the pixels sharp and on-brand with the +in-app pixel-art aesthetic. + +Usage: + python3 tools/make_icon.py + +Outputs (1024x1024, opaque RGB — App Store compliant, no alpha): + LiarsDice/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png (primary) + docs/icon-concepts/concept-bones.png + docs/icon-concepts/concept-clean.png + docs/icon-concepts/concept-gold.png +""" + +import os +import zlib +import struct + +N = 64 # logical grid size +SCALE = 16 # 64 * 16 = 1024 + +# ---- Palette (matches LiarsDice/Theme/Theme.swift) ------------------------- +NAVY = (0x14, 0x15, 0x1F) +NAVY_DK = (0x0C, 0x0D, 0x14) +TEAL_HI = (0x37, 0x86, 0x6E) +GOLD = (0xF2, 0xC1, 0x4E) +GOLD_DK = (0xB8, 0x88, 0x2A) +BONE = (0xF6, 0xF0, 0xE2) +BONE_HI = (0xFF, 0xFC, 0xF2) +PIP = (0x23, 0x20, 0x1A) +PARCH = (0xD6, 0xC4, 0x96) +PARCH_DK = (0x8A, 0x73, 0x44) +INK = (0x24, 0x1B, 0x12) + + +def new_canvas(): + return [[list(NAVY) for _ in range(N)] for _ in range(N)] + + +def put(c, x, y, color, a=1.0): + xi, yi = int(x), int(y) + if 0 <= xi < N and 0 <= yi < N: + if a >= 1.0: + c[yi][xi] = list(color) + else: + bg = c[yi][xi] + c[yi][xi] = [int(bg[i] * (1 - a) + color[i] * a) for i in range(3)] + + +def lerp(a, b, t): + return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) + + +def background(c, center, edge): + cx, cy = (N - 1) / 2, (N - 1) / 2 + maxd = (cx ** 2 + cy ** 2) ** 0.5 + for y in range(N): + for x in range(N): + d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 / maxd + c[y][x] = list(lerp(center, edge, min(1.0, d ** 1.1))) + + +def disc(c, cx, cy, r, color, a=1.0): + for y in range(int(cy - r) - 1, int(cy + r) + 2): + for x in range(int(cx - r) - 1, int(cx + r) + 2): + if (x - cx) ** 2 + (y - cy) ** 2 <= r * r: + put(c, x, y, color, a) + + +def round_rect(c, x0, y0, x1, y1, rad, color, a=1.0): + for y in range(int(y0), int(y1) + 1): + for x in range(int(x0), int(x1) + 1): + inside = True + for (cx, cy) in ((x0 + rad, y0 + rad), (x1 - rad, y0 + rad), + (x0 + rad, y1 - rad), (x1 - rad, y1 - rad)): + if ((x < x0 + rad and y < y0 + rad and (cx, cy) == (x0 + rad, y0 + rad)) or + (x > x1 - rad and y < y0 + rad and (cx, cy) == (x1 - rad, y0 + rad)) or + (x < x0 + rad and y > y1 - rad and (cx, cy) == (x0 + rad, y1 - rad)) or + (x > x1 - rad and y > y1 - rad and (cx, cy) == (x1 - rad, y1 - rad))): + if (x - cx) ** 2 + (y - cy) ** 2 > rad * rad: + inside = False + if inside: + put(c, x, y, color, a) + + +def thick_line(c, x0, y0, x1, y1, r, color): + steps = int(max(abs(x1 - x0), abs(y1 - y0)) * 2) + 1 + for i in range(steps + 1): + t = i / steps + x = x0 + (x1 - x0) * t + y = y0 + (y1 - y0) * t + disc(c, x, y, r, color) + + +def crossed_bones(c): + # two bones forming an X behind the die + bones = (((15, 15), (49, 49)), ((49, 15), (15, 49))) + for (a, b) in bones: + thick_line(c, a[0], a[1], b[0], b[1], 2.2, PARCH_DK) + thick_line(c, a[0], a[1], b[0], b[1], 1.4, PARCH) + # knuckle knobs at each end + for (kx, ky) in (a, b): + disc(c, kx, ky - 2, 2.1, PARCH_DK); disc(c, kx, ky - 2, 1.3, PARCH) + disc(c, kx, ky + 2, 2.1, PARCH_DK); disc(c, kx, ky + 2, 1.3, PARCH) + + +def die(c, cx, cy, half, body=BONE): + """A bone die showing five pips (the brand face).""" + x0, y0, x1, y1 = cx - half, cy - half, cx + half, cy + half + rad = max(2, half * 0.32) + round_rect(c, x0 + 2, y0 + 3, x1 + 2, y1 + 3, rad, (0, 0, 0), a=0.30) # shadow + round_rect(c, x0 - 1, y0 - 1, x1 + 1, y1 + 1, rad + 1, INK) # outline + round_rect(c, x0, y0, x1, y1, rad, body) # body + round_rect(c, x0 + 2, y0 + 2, x1 - 2, y0 + 4, rad * 0.5, BONE_HI, a=0.6) # highlight + + inset = half * 0.42 + lo_x, hi_x = x0 + inset, x1 - inset + lo_y, hi_y = y0 + inset, y1 - inset + pr = max(2.0, half * 0.16) + for (px, py) in ((lo_x, lo_y), (hi_x, lo_y), (cx, cy), (lo_x, hi_y), (hi_x, hi_y)): + disc(c, px, py, pr + 0.6, INK) + disc(c, px, py, pr, PIP) + + +def compose(bg=(TEAL_HI, NAVY_DK), die_body=BONE, with_bones=True): + c = new_canvas() + background(c, bg[0], bg[1]) + # gold frame ring, then re-blit the interior so the frame reads as a ring + round_rect(c, 3, 3, N - 4, N - 4, 7, GOLD_DK) + round_rect(c, 4, 4, N - 5, N - 5, 6, GOLD) + inner = new_canvas() + background(inner, bg[0], bg[1]) + for y in range(7, N - 7): + for x in range(7, N - 7): + c[y][x] = list(inner[y][x]) + if with_bones: + crossed_bones(c) + die(c, 32, 33, 16, body=die_body) + disc(c, 24, 25, 1.4, BONE_HI, a=0.9) # small glint + return c + + +def write_png(path, canvas): + w = h = N * SCALE + raw = bytearray() + for row in canvas: + line = bytearray() + for (r, g, b) in row: + line += bytes((r, g, b)) * SCALE + for _ in range(SCALE): + raw += b'\x00' + line + + def chunk(tag, data): + return (struct.pack(">I", len(data)) + tag + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xffffffff)) + + png = b'\x89PNG\r\n\x1a\n' + png += chunk(b'IHDR', struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + png += chunk(b'IDAT', zlib.compress(bytes(raw), 9)) + png += chunk(b'IEND', b'') + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, 'wb') as f: + f.write(png) + print("wrote", path, f"({w}x{h})") + + +def main(): + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + appicon = os.path.join(here, "LiarsDice/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png") + concepts = os.path.join(here, "docs/icon-concepts") + + write_png(appicon, compose()) # primary == bones + write_png(os.path.join(concepts, "concept-bones.png"), compose()) + write_png(os.path.join(concepts, "concept-clean.png"), compose(with_bones=False)) + write_png(os.path.join(concepts, "concept-gold.png"), + compose(bg=(NAVY, NAVY_DK), die_body=GOLD, with_bones=True)) + + +if __name__ == "__main__": + main() diff --git a/tools/make_sfx.py b/tools/make_sfx.py new file mode 100644 index 0000000..3968e6b --- /dev/null +++ b/tools/make_sfx.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +""" +Generate the game's chiptune sound effects as small 8-bit-style WAV files, +using only the Python standard library (`wave`). No samples to license, fully +reproducible, and on-brand with the retro pixel-art look. + +Usage: + python3 tools/make_sfx.py + +Outputs (16-bit mono, 22.05 kHz) into LiarsDice/Resources/Audio/: + roll, bid, challenge, reveal_good, reveal_lie, lose_die, win, lose +""" + +import os +import wave +import struct +import math +import random + +SR = 22050 +random.seed(7) + + +def envelope(i, n, attack, release): + t = i / SR + dur = n / SR + a = min(1.0, t / attack) if attack > 0 else 1.0 + rem = max(0.0, dur - t) + r = min(1.0, rem / release) if release > 0 else 1.0 + return a * r + + +def wave_sample(kind, freq, t, duty=0.5): + if kind == "square": + return 1.0 if (t * freq) % 1.0 < duty else -1.0 + if kind == "triangle": + return 4.0 * abs(((t * freq) % 1.0) - 0.5) - 1.0 + if kind == "noise": + return random.uniform(-1.0, 1.0) + return math.sin(2 * math.pi * freq * t) + + +def note(buf, start, dur, freq, kind="square", vol=0.4, + glide=None, attack=0.005, release=None, duty=0.5): + n = int(dur * SR) + if release is None: + release = dur * 0.6 + for i in range(n): + t = i / SR + f = freq if glide is None else freq + (glide - freq) * (i / n) + s = wave_sample(kind, f, t, duty) + idx = start + i + if 0 <= idx < len(buf): + buf[idx] += s * vol * envelope(i, n, attack, release) + + +def buffer(dur): + return [0.0] * int(dur * SR) + + +def write_wav(path, buf): + frames = bytearray() + for s in buf: + v = max(-1.0, min(1.0, s)) + frames += struct.pack("