commit 8b14ecb2be902030f1308945aa60ab7d66226861
parent a6f2a3c58b2ec66bfc1e550ba9afb40741aac504
Author: Michael Camilleri <[email protected]>
Date: Mon, 6 Jul 2026 20:03:10 +0900
Support generating iPad marketing images
Diffstat:
4 files changed, 421 insertions(+), 28 deletions(-)
diff --git a/Crossmate/Views/MarketingScreenshots.swift b/Crossmate/Views/MarketingScreenshots.swift
@@ -113,7 +113,12 @@ private struct MarketingPuzzleSceneView: View {
private static func makeModel(
scene: MarketingScene
) -> (session: PlayerSession, gameID: UUID, replayTimeline: ReplayTimeline?) {
- let puzzle = try! Puzzle(xd: XD.parse(source))
+ let puzzle: Puzzle
+ do {
+ puzzle = try Puzzle(xd: XD.parse(source))
+ } catch {
+ fatalError("Marketing screenshot puzzle failed to parse: \(error)")
+ }
let game = Game(puzzle: puzzle)
let gameID = UUID(uuidString: "43524F53-534D-4154-452D-53484F545321")!
let movesJournal = scene == .undoRedo
@@ -246,7 +251,55 @@ private struct MarketingPuzzleSceneView: View {
DANAE#ORES#ADES
+ A1. High voice ~ ALTO
+ A5. Fairy-tale brute ~ OGRE
+ A9. Pan player ~ PIPER
+ A14. Police action ~ RAID
+ A15. Park shade ~ TREE
+ A16. Turkish city ~ ADANA
+ A17. City transport, briefly ~ MUNI
+ A18. Garden tool user ~ HOER
+ A19. Rare, with a flourish ~ RAREE
+ A20. Investment legend's nickname ~ ORACLEOFOMAHA
+ A23. Fraternity letters ~ RAS
+ A24. Charity gifts ~ ALMS
+ A25. Studio apartments ~ ADOBES
+ A28. Pronoun pair ~ IT
+ A29. Black gold ~ OIL
+ A30. Charge ~ FEE
+ A31. Yoko of music ~ ONO
+ A32. Sci-fi hero Rogers ~ BUCK
+ A33. Rabbit, on screen ~ ROGER
+ A36. Scheme ~ PLAN
A37. The best crossword app, perhaps ~ CROSSMATE
+ A40. Nin memoirist ~ ANAIS
+ A43. A little bit of data ~ ATA
+ A44. Wonderland girl ~ ALICE
+ A48. Noble title ~ SIRE
+ A49. Brings back ~ RESURRECTS
+ A51. Family boy ~ SON
+ A52. Public figure, briefly ~ POL
+ A53. Also ~ AND
+ A54. Bandit descriptor ~ ONEARM
+ A56. Animal charity initials ~ SPCA
+ A58. Pasture bleat ~ BAA
+ A61. Comic strip oath ~ LEAPINLIZARDS
+ A64. Island nation ~ HAITI
+ A66. Pods contents ~ PEAS
+ A67. Spots ~ SEES
+ A68. Mythic mother ~ DANAE
+ A69. Mine finds ~ ORES
+ A70. Fruity drinks ~ ADES
+ D1. Down entry ~ ARMORIB
+ D2. Down entry ~ LAURATU
+ D3. Down entry ~ TINAS
+ D4. Down entry ~ ODIC
+ D5. Down entry ~ OTHELLRO
+ D6. Down entry ~ GROOM
+ D7. Down entry ~ REEFS
+ D8. Down entry ~ EERO
+ D9. Down entry ~ PARADE
+ D10. Down entry ~ IDAHO
"""
}
@@ -254,6 +307,37 @@ private struct MarketingImportScreenshotView: View {
@State private var selection = PuzzleSource.imported
var body: some View {
+ GeometryReader { proxy in
+ let isPad = UIDevice.current.userInterfaceIdiom == .pad
+
+ ZStack {
+ if isPad {
+ MarketingGameListBackdrop()
+ } else {
+ Color(.systemGroupedBackground)
+ .ignoresSafeArea()
+ }
+
+ sheetContent
+ .frame(
+ width: isPad ? min(proxy.size.width * 0.58, 760) : proxy.size.width,
+ height: isPad ? min(proxy.size.height * 0.86, 920) : proxy.size.height
+ )
+ .clipShape(RoundedRectangle(cornerRadius: isPad ? 18 : 0, style: .continuous))
+ .shadow(color: isPad ? Color.black.opacity(0.18) : .clear, radius: 22, y: 10)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ .background {
+ if isPad {
+ Color.black.opacity(0.08)
+ .ignoresSafeArea()
+ }
+ }
+ }
+ .preferredColorScheme(.light)
+ }
+
+ private var sheetContent: some View {
NavigationStack {
VStack(spacing: 0) {
Picker("Source", selection: $selection) {
@@ -288,8 +372,254 @@ private struct MarketingImportScreenshotView: View {
}
}
}
- .preferredColorScheme(.light)
}
}
+private struct MarketingGameListBackdrop: View {
+ private let columns = [GridItem(.adaptive(minimum: 380), spacing: 12)]
+
+ var body: some View {
+ NavigationStack {
+ ScrollView {
+ LazyVStack(spacing: 8) {
+ section("In Progress") {
+ LazyVGrid(columns: columns, spacing: 12) {
+ gameCard(
+ title: "Crossmate",
+ publisher: "Collaborative Crossword App",
+ updated: "Last updated 2 minutes ago",
+ isShared: true,
+ cells: Self.crossmateCells
+ )
+ gameCard(
+ title: "Weekend Cryptic",
+ publisher: "Imported",
+ updated: "Last updated 1 hour ago",
+ isShared: false,
+ cells: Self.crypticCells
+ )
+ }
+ }
+
+ section("Completed") {
+ LazyVGrid(columns: columns, spacing: 12) {
+ gameCard(
+ title: "Morning Mini",
+ publisher: "Crossmate",
+ updated: "Last updated 3 hours ago",
+ isShared: false,
+ cells: Self.miniCells
+ )
+ gameCard(
+ title: "Tournament Pack",
+ publisher: "Across Lite",
+ updated: "Last updated on 3 Jul 2026",
+ isShared: true,
+ cells: Self.tournamentCells
+ )
+ }
+ }
+ }
+ .padding(.vertical, 8)
+ }
+ .background(Color(.systemGroupedBackground))
+ .navigationTitle("")
+ .navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .topBarLeading) {
+ Button {} label: {
+ Image(systemName: "gearshape")
+ }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {} label: {
+ Image(systemName: "person.2")
+ }
+ }
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {} label: {
+ Image(systemName: "plus")
+ }
+ }
+ }
+ }
+ .disabled(true)
+ .ignoresSafeArea()
+ }
+
+ private func section<Content: View>(
+ _ title: String,
+ @ViewBuilder content: () -> Content
+ ) -> some View {
+ Section {
+ content()
+ .padding(.horizontal)
+ } header: {
+ Text(title)
+ .font(.footnote.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(Color(.systemGroupedBackground))
+ }
+ }
+
+ private func gameCard(
+ title: String,
+ publisher: String,
+ updated: String,
+ isShared: Bool,
+ cells: [GameThumbnailCell]
+ ) -> some View {
+ HStack(spacing: 12) {
+ VStack(spacing: 0) {
+ GridThumbnailView(width: 15, height: 15, cells: cells, size: 60)
+ if isShared {
+ participantStrip
+ .frame(width: 60)
+ .padding(.top, 2)
+ }
+ }
+ .frame(width: 60)
+
+ VStack(alignment: .leading, spacing: 2) {
+ HStack(spacing: 4) {
+ Text(title)
+ .font(.headline)
+ .lineLimit(1)
+ if isShared {
+ Image(systemName: "person.2.fill")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ }
+ }
+ Text(publisher)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ Text(updated)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ }
+
+ Spacer(minLength: 0)
+
+ Image(systemName: "ellipsis.circle")
+ .font(.title3)
+ .foregroundStyle(.secondary)
+ .frame(width: 32, height: 32)
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity)
+ .frame(height: CardMetrics.height)
+ .background(
+ Color(.secondarySystemGroupedBackground),
+ in: RoundedRectangle(cornerRadius: CardMetrics.cornerRadius)
+ )
+ }
+
+ private var participantStrip: some View {
+ HStack(spacing: 0) {
+ Rectangle()
+ .fill(Color.blue)
+ Rectangle()
+ .fill(Color(.separator))
+ .frame(width: 0.5)
+ Rectangle()
+ .fill(Color.red)
+ }
+ .frame(maxWidth: .infinity)
+ .frame(height: 6)
+ }
+
+ private static func cells(_ rows: [String]) -> [GameThumbnailCell] {
+ rows.flatMap { row in
+ row.map { ch in
+ switch ch {
+ case "#":
+ .block
+ case "x", "X":
+ .filled
+ default:
+ .empty
+ }
+ }
+ }
+ }
+
+ private static let crossmateCells = cells([
+ "....#....#.....",
+ "....#....#.....",
+ "....#....#...##",
+ ".............##",
+ "...#....#......",
+ "..#...##...#...",
+ "....#.....#....",
+ "###xxxxxx...###",
+ ".....#...#.....",
+ "....#..........",
+ "...#...##...###",
+ "......#....#...",
+ "##.............",
+ ".....#....#....",
+ ".....#....#....",
+ ])
+
+ private static let crypticCells = cells([
+ "....#....#.....",
+ "..#......#.....",
+ "......#.....#..",
+ ".#.......#.....",
+ ".....###.......",
+ "....#.....#....",
+ "...#.......#...",
+ "##....#....#...",
+ "...#.......#...",
+ "....#.....#....",
+ ".......###.....",
+ ".....#.......#.",
+ "..#.....#......",
+ ".....#......#..",
+ ".....#....#....",
+ ])
+
+ private static let miniCells = cells([
+ ".....#....#....",
+ ".xxx.#....#....",
+ ".xxx.#....#....",
+ ".xxx...........",
+ ".....#....#....",
+ "##....#....#...",
+ ".....#....#....",
+ "....#....#.....",
+ "....#....#.....",
+ "..#....#....##.",
+ "....#....#.....",
+ "..........#....",
+ "....#....#.xxx.",
+ "....#....#.xxx.",
+ "....#....#.....",
+ ])
+
+ private static let tournamentCells = cells([
+ "....#....#.....",
+ ".xxxx....#.....",
+ ".xxxx....#..##.",
+ ".xxxx..........",
+ "...#....#......",
+ "..#...##...#...",
+ "....#.....#....",
+ "###....xxxx.###",
+ ".....#...#.....",
+ "....#..........",
+ "...#...##...###",
+ "......#...xxxx.",
+ "##........xxxx.",
+ ".....#....xxxx.",
+ ".....#....#....",
+ ])
+}
+
#endif
diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift
@@ -85,6 +85,11 @@ struct PuzzleView: View {
case portrait
}
+ private var effectivePadLayout: PadLayout? {
+ guard UIDevice.current.userInterfaceIdiom == .pad else { return nil }
+ return padLayout ?? .portrait
+ }
+
private func swatchImage(for color: PlayerColor) -> Image {
let tint = UIColor(color.tint)
let base = UIImage(systemName: "circle.fill") ?? UIImage()
@@ -128,7 +133,7 @@ struct PuzzleView: View {
var body: some View {
Group {
- switch padLayout {
+ switch effectivePadLayout {
case .landscape:
landscapePadLayout
case .portrait:
@@ -338,8 +343,12 @@ struct PuzzleView: View {
return
}
let newLayout: PadLayout?
- if UIDevice.current.userInterfaceIdiom == .pad, size != .zero {
- newLayout = size.width > size.height ? .landscape : .portrait
+ if UIDevice.current.userInterfaceIdiom == .pad {
+ if Self.activeWindowSceneOrientation?.isLandscape == true {
+ newLayout = .landscape
+ } else {
+ newLayout = size.width > size.height ? .landscape : .portrait
+ }
} else {
newLayout = nil
}
@@ -350,6 +359,13 @@ struct PuzzleView: View {
}
}
+ private static var activeWindowSceneOrientation: UIInterfaceOrientation? {
+ UIApplication.shared.connectedScenes
+ .compactMap { $0 as? UIWindowScene }
+ .first { $0.activationState == .foregroundActive }?
+ .interfaceOrientation
+ }
+
private func performResign() {
do {
try onResign?()
@@ -401,7 +417,7 @@ struct PuzzleView: View {
roster: roster,
title: titleParts.title,
subtitle: titleParts.subtitle,
- showsScoreboard: padLayout == nil,
+ showsScoreboard: effectivePadLayout == nil,
shouldAutoRevealScoreboard: shouldAutoRevealScoreboard,
gameID: session.mutator.gameID,
isEngagementLive: engagementStatus?.isLive(gameID: session.mutator.gameID) == true,
@@ -470,7 +486,7 @@ struct PuzzleView: View {
.transition(.move(edge: .bottom))
} else if showsCustomKeyboard {
ControlsView(height: controlsPanelHeight) {
- KeyboardView(session: session, showsNavigationKeys: padLayout != nil)
+ KeyboardView(session: session, showsNavigationKeys: effectivePadLayout != nil)
.opacity(isInputBlocked ? 0.4 : 1)
.allowsHitTesting(!isInputBlocked)
.animation(.easeInOut(duration: 0.3), value: isInputBlocked)
diff --git a/Scripts/screenshots-ios-compose.swift b/Scripts/screenshots-ios-compose.swift
@@ -23,9 +23,9 @@ let canvasHeight: CGFloat =
CommandLine.arguments.count >= 6 ? CGFloat(Int(CommandLine.arguments[5]) ?? 2868) : 2868
let backgroundColor = CGColor(
- red: 29.0 / 255.0,
- green: 37.0 / 255.0,
- blue: 48.0 / 255.0,
+ red: 228.0 / 255.0,
+ green: 242.0 / 255.0,
+ blue: 255.0 / 255.0,
alpha: 1.0
)
@@ -84,7 +84,7 @@ let fontSize: CGFloat = isPad ? 118 : (isLandscape ? canvasHeight * 0.06 : canva
let font = NSFont.systemFont(ofSize: fontSize, weight: .bold)
let attributes: [NSAttributedString.Key: Any] = [
.font: font,
- .foregroundColor: NSColor.white,
+ .foregroundColor: NSColor(calibratedRed: 17 / 255, green: 24 / 255, blue: 39 / 255, alpha: 1),
]
let attrString = NSAttributedString(string: text, attributes: attributes)
let ctLine = CTLineCreateWithAttributedString(attrString)
diff --git a/Scripts/screenshots-marketing.sh b/Scripts/screenshots-marketing.sh
@@ -25,10 +25,27 @@ case "$PLATFORM" in
;;
esac
+set_simulator_landscape_left() {
+ osascript \
+ -e 'tell application "Simulator" to activate' \
+ -e 'delay 0.2' \
+ -e 'tell application "System Events" to tell process "Simulator" to click menu item "Landscape Left" of menu 1 of menu item "Orientation" of menu "Device" of menu bar 1'
+}
+
+set_ipad_landscape() {
+ local udid="$1"
+
+ open -a Simulator --args -CurrentDeviceUDID "$udid" >/dev/null 2>&1 || true
+ sleep 1
+ set_simulator_landscape_left
+ sleep 1
+}
+
if [ "$PLATFORM" = "iphone" ]; then
select_simulator "$MAJOR"
CANVAS_ARGS=()
else
+ MARKETING_IPAD_NAME="Crossmate Marketing iPad"
RUNTIME=$(xcrun simctl list runtimes available \
| grep "iOS ${MAJOR}\." \
| sed 's/.*iOS \([0-9.]*\).*/\1/' \
@@ -43,29 +60,50 @@ else
echo "No available iOS ${MAJOR}.x simulator runtime found." >&2
exit 1
fi
- DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
- | grep "iPad Pro 13" \
+
+ UDID=$(xcrun simctl list devices \
+ | grep -F "${MARKETING_IPAD_NAME} (" \
| head -1 \
- | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/' || true)
- if [ -z "$DEVICE" ]; then
- DEVICE=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
- | grep "iPad" \
+ | sed -E 's/.*\(([0-9A-Fa-f-]{36})\).*/\1/' || true)
+ if [ -n "$UDID" ]; then
+ DEVICE="$MARKETING_IPAD_NAME"
+ else
+ DEVICE_TYPE_LINE=$(xcrun simctl list devicetypes \
+ | grep -F "iPad Pro 13-inch" \
+ | grep -v "16GB" \
| head -1 \
- | sed 's/^ *\(.*\) ([A-F0-9-]*).*/\1/' || true)
- fi
- if [ -z "$DEVICE" ]; then
- echo "No available iPad simulator found for iOS ${RUNTIME}." >&2
- exit 1
+ || true)
+ if [ -z "$DEVICE_TYPE_LINE" ]; then
+ DEVICE_TYPE_LINE=$(xcrun simctl list devicetypes \
+ | grep -F "iPad" \
+ | head -1 \
+ || true)
+ fi
+ DEVICE=$(sed -E 's/ \(com\.apple[^)]*\)$//' <<<"$DEVICE_TYPE_LINE")
+ DEVICE_TYPE_ID=$(sed -E 's/.*\((com\.apple[^)]*)\)$/\1/' <<<"$DEVICE_TYPE_LINE")
+ RUNTIME_ID=$(xcrun simctl list runtimes available \
+ | grep "iOS ${RUNTIME} (" \
+ | head -1 \
+ | sed -E 's/.* - (com\.apple[^ ]*).*/\1/' || true)
+ if [ -z "$DEVICE_TYPE_ID" ] || [ -z "$RUNTIME_ID" ]; then
+ echo "Couldn't resolve a device type / runtime to create '${MARKETING_IPAD_NAME}'." >&2
+ exit 1
+ fi
+ echo "Creating '${MARKETING_IPAD_NAME}' (${DEVICE}, iOS ${RUNTIME})"
+ UDID=$(xcrun simctl create "${MARKETING_IPAD_NAME}" "${DEVICE_TYPE_ID}" "${RUNTIME_ID}")
+ DEVICE="$MARKETING_IPAD_NAME"
fi
- CANVAS_ARGS=(2064 2752)
+ CANVAS_ARGS=(2752 2064)
fi
echo "Using ${DEVICE}, iOS ${RUNTIME}"
-UDID=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
- | grep "$DEVICE" \
- | head -1 \
- | sed 's/.*(\([A-F0-9-]*\)).*/\1/')
+if [ -z "${UDID:-}" ]; then
+ UDID=$(xcrun simctl list devices available "iOS ${RUNTIME}" \
+ | grep "$DEVICE" \
+ | head -1 \
+ | sed 's/.*(\([A-F0-9-]*\)).*/\1/')
+fi
xcrun simctl boot "$UDID" 2>/dev/null || true
xcrun simctl bootstatus "$UDID" -b
@@ -98,11 +136,15 @@ CAPTIONS=(
rm -rf "$DERIVED_DATA" "$WORK_DIR"
mkdir -p "$WORK_DIR" "$MARKETING_DIR"
+if [ "$PLATFORM" = "ipad" ]; then
+ set_ipad_landscape "$UDID"
+fi
+
xcodebuild build \
-quiet \
-scheme "Crossmate" \
-project "${REPO_DIR}/Crossmate.xcodeproj" \
- -destination "platform=iOS Simulator,name=${DEVICE},OS=${DESTINATION_OS}" \
+ -destination "id=${UDID}" \
-derivedDataPath "$DERIVED_DATA"
APP_PATH="${DERIVED_DATA}/Build/Products/Debug-iphonesimulator/Crossmate.app"
@@ -117,6 +159,7 @@ for index in "${!SCENES[@]}"; do
scene="${SCENES[$index]}"
caption="${CAPTIONS[$index]}"
raw_path="${WORK_DIR}/${scene}.png"
+ rotated_path="${WORK_DIR}/${scene}-rotated.png"
island_path="${WORK_DIR}/${scene}-island.png"
framed_path="${WORK_DIR}/${scene}-framed.png"
output_path="${MARKETING_DIR}/${PLATFORM}_${index}.png"
@@ -139,6 +182,10 @@ for index in "${!SCENES[@]}"; do
xcrun simctl io "$UDID" screenshot --mask ignored "$raw_path"
frame_input="$raw_path"
+ if [ "$PLATFORM" = "ipad" ]; then
+ sips -r 270 "$raw_path" --out "$rotated_path" >/dev/null
+ frame_input="$rotated_path"
+ fi
if [ "$PLATFORM" = "iphone" ] && [ "$ADD_DYNAMIC_ISLAND" = true ]; then
swift "${SCRIPT_DIR}/screenshots-iphone-island.swift" "$raw_path" "$island_path"
frame_input="$island_path"