Brand the app with the Wallace tartan and a Celtic splash
- The launcher icon is a single square of the tartan, drawn as a vector so it stays sharp at any density and ships no PNGs. The adaptive icon's foreground is deliberately empty: the cloth is the icon. - Tartan.kt draws the sett as warp, then as weft at half opacity. That overlap is what gives the blended squares and reads as woven cloth rather than a printed grid, and it is reused by both the icon and the splash. - The app opens on the tartan under "Pro Libertate Games" in Uncial Antiqua, holds two seconds, then fades. It is an overlay rather than a destination, so the menu is already composed behind it and the fade reveals a live screen. The window background is set to a dark red so launch does not flash white before Compose draws. Two things to be aware of: - WallaceSett is an approximation. It reads as Wallace — bold red and black check with a narrow gold overstripe — but the registered threadcount could not be verified here, so the counts were chosen to look right. They are plain data and can be swapped for the registered ones without touching anything else. - Uncial Antiqua is bundled under the SIL Open Font License 1.1, with the full licence at docs/licenses/UncialAntiqua-OFL.txt. The OFL requires that to be distributed with the font, so it must not be separated from it. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015qjfMiPUDshdKEUa6vxqzN
This commit is contained in:
@@ -82,11 +82,9 @@ fun AppRoot(env: AppEnv) {
|
||||
env.updater.installedChannel() == UpdateChannel.DEV
|
||||
}
|
||||
|
||||
fun push(route: Route) {
|
||||
stack = stack + route
|
||||
}
|
||||
val push: (Route) -> Unit = { route -> stack = stack + route }
|
||||
|
||||
fun pop() {
|
||||
val pop: () -> Unit = {
|
||||
if (stack.size > 1) {
|
||||
// Leaving a lobby or a table tears the networking down with it.
|
||||
when (stack.last()) {
|
||||
@@ -99,6 +97,35 @@ fun AppRoot(env: AppEnv) {
|
||||
|
||||
BackHandler(enabled = stack.size > 1) { pop() }
|
||||
|
||||
var splashDone by remember { mutableStateOf(false) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AppContent(
|
||||
env = env,
|
||||
settings = settings,
|
||||
stack = stack,
|
||||
showComingSoon = showComingSoon,
|
||||
push = push,
|
||||
pop = pop,
|
||||
)
|
||||
|
||||
// Over the top, so the menu behind it is already composed when it fades.
|
||||
if (!splashDone) {
|
||||
SplashScreen(onFinished = { splashDone = true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The navigation stack proper, kept separate so the splash can sit over it. */
|
||||
@Composable
|
||||
private fun AppContent(
|
||||
env: AppEnv,
|
||||
settings: Settings,
|
||||
stack: List<Route>,
|
||||
showComingSoon: Boolean,
|
||||
push: (Route) -> Unit,
|
||||
pop: () -> Unit,
|
||||
) {
|
||||
when (val route = stack.last()) {
|
||||
is Route.Menu -> MainMenuScreen(
|
||||
// Dev builds show the games that are not finished yet; production
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package org.prolibertate.games.ui
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.Font
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.delay
|
||||
import org.prolibertate.games.R
|
||||
import org.prolibertate.games.ui.theme.TartanBackground
|
||||
|
||||
/** How long the splash holds at full opacity before fading. */
|
||||
private const val SPLASH_HOLD_MILLIS = 2_000L
|
||||
|
||||
private const val SPLASH_FADE_MILLIS = 600
|
||||
|
||||
/**
|
||||
* Uncial Antiqua — a Celtic uncial face, bundled under the SIL Open Font
|
||||
* License. See docs/licenses/UncialAntiqua-OFL.txt.
|
||||
*/
|
||||
val CelticFontFamily = FontFamily(Font(R.font.uncial_antiqua))
|
||||
|
||||
/**
|
||||
* The launch screen: Wallace tartan under the name of the house.
|
||||
*
|
||||
* Drawn as an overlay rather than a separate destination, so the menu is
|
||||
* already composed and ready behind it and the fade reveals a live screen
|
||||
* instead of a blank one.
|
||||
*/
|
||||
@Composable
|
||||
fun SplashScreen(
|
||||
onFinished: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var fading by remember { mutableStateOf(false) }
|
||||
val alpha by animateFloatAsState(
|
||||
targetValue = if (fading) 0f else 1f,
|
||||
animationSpec = tween(durationMillis = SPLASH_FADE_MILLIS),
|
||||
label = "splashFade",
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
delay(SPLASH_HOLD_MILLIS)
|
||||
fading = true
|
||||
delay(SPLASH_FADE_MILLIS.toLong())
|
||||
onFinished()
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize().alpha(alpha)) {
|
||||
// Weave scale follows the screen so the cloth reads the same on a phone
|
||||
// and a tablet rather than turning into a fine grid on a large display.
|
||||
val threadWidth = (minOf(maxWidth, maxHeight).value * 0.012f).dp
|
||||
TartanBackground(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
threadWidth = threadWidth,
|
||||
)
|
||||
|
||||
// A scrim keeps the lettering legible over the busiest part of the sett.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.45f))
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 24.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Pro Libertate",
|
||||
style = TextStyle(
|
||||
fontFamily = CelticFontFamily,
|
||||
fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp,
|
||||
color = Color(0xFFF7EFD8),
|
||||
textAlign = TextAlign.Center,
|
||||
shadow = Shadow(
|
||||
color = Color.Black,
|
||||
offset = Offset(0f, 3f),
|
||||
blurRadius = 10f,
|
||||
),
|
||||
),
|
||||
)
|
||||
Text(
|
||||
text = "Games",
|
||||
style = TextStyle(
|
||||
fontFamily = CelticFontFamily,
|
||||
fontSize = (minOf(maxWidth, maxHeight).value * 0.13f).sp,
|
||||
color = Color(0xFFF2C200),
|
||||
textAlign = TextAlign.Center,
|
||||
shadow = Shadow(
|
||||
color = Color.Black,
|
||||
offset = Offset(0f, 3f),
|
||||
blurRadius = 10f,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package org.prolibertate.games.ui.theme
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/** One band of a tartan sett, measured in threads. */
|
||||
data class TartanStripe(val color: Color, val threads: Int)
|
||||
|
||||
val WallaceRed = Color(0xFFB3121B)
|
||||
val WallaceBlack = Color(0xFF111111)
|
||||
val WallaceGold = Color(0xFFF2C200)
|
||||
|
||||
/**
|
||||
* The Wallace sett.
|
||||
*
|
||||
* NOTE: this is an approximation, not a certified threadcount. It reproduces
|
||||
* what the Wallace tartan reads as — a bold red and black check divided by a
|
||||
* narrow gold overstripe — but the exact thread counts held by the Scottish
|
||||
* Register of Tartans could not be verified from here, so the numbers below
|
||||
* are chosen to look right rather than to be authoritative. They are plain
|
||||
* data: replacing them with the registered counts changes the whole app's
|
||||
* branding and needs no other edit.
|
||||
*/
|
||||
val WallaceSett: List<TartanStripe> = listOf(
|
||||
TartanStripe(WallaceRed, 40),
|
||||
TartanStripe(WallaceBlack, 40),
|
||||
TartanStripe(WallaceGold, 4),
|
||||
)
|
||||
|
||||
/**
|
||||
* Draws a tartan by laying the sett down vertically as warp, then across as
|
||||
* weft at half opacity. The overlap is what produces the blended squares and
|
||||
* the sense of a woven cloth rather than a printed grid — red warp under black
|
||||
* weft comes out a darker red, exactly as it does on the loom.
|
||||
*/
|
||||
fun DrawScope.drawTartan(
|
||||
sett: List<TartanStripe> = WallaceSett,
|
||||
threadSize: Float,
|
||||
) {
|
||||
if (sett.isEmpty() || threadSize <= 0f) return
|
||||
val repeat = sett.sumOf { it.threads } * threadSize
|
||||
if (repeat <= 0f) return
|
||||
|
||||
// Warp: full-height bands.
|
||||
var x = 0f
|
||||
while (x < size.width) {
|
||||
var offset = x
|
||||
for (stripe in sett) {
|
||||
val width = stripe.threads * threadSize
|
||||
drawRect(
|
||||
color = stripe.color,
|
||||
topLeft = Offset(offset, 0f),
|
||||
size = Size(width, size.height),
|
||||
)
|
||||
offset += width
|
||||
}
|
||||
x += repeat
|
||||
}
|
||||
|
||||
// Weft: full-width bands, half opacity so the warp shows through.
|
||||
var y = 0f
|
||||
while (y < size.height) {
|
||||
var offset = y
|
||||
for (stripe in sett) {
|
||||
val height = stripe.threads * threadSize
|
||||
drawRect(
|
||||
color = stripe.color.copy(alpha = 0.5f),
|
||||
topLeft = Offset(0f, offset),
|
||||
size = Size(size.width, height),
|
||||
)
|
||||
offset += height
|
||||
}
|
||||
y += repeat
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tartan fill. [threadWidth] sets the weave scale, so the same cloth can be
|
||||
* a fine weave on a phone and a coarse one on a tablet without redrawing it.
|
||||
*/
|
||||
@Composable
|
||||
fun TartanBackground(
|
||||
modifier: Modifier = Modifier,
|
||||
sett: List<TartanStripe> = WallaceSett,
|
||||
threadWidth: Dp = 2.dp,
|
||||
) {
|
||||
Canvas(modifier = modifier) {
|
||||
drawTartan(sett = sett, threadSize = threadWidth.toPx())
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,59 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
A single square of the Wallace tartan.
|
||||
|
||||
The sett is laid down as warp (opaque, vertical) over a black ground, then as
|
||||
weft (half opacity, horizontal), which is what produces the blended squares
|
||||
rather than a flat grid. One full repeat is 72 units wide, so the centre
|
||||
72x72 safe zone of the adaptive icon shows exactly one repeat.
|
||||
|
||||
Thread proportions match WallaceSett in Tartan.kt — see the note there about
|
||||
these being a visual approximation rather than the registered threadcount.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- Solid grass-green field. -->
|
||||
|
||||
<!-- Ground -->
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
|
||||
<!-- Warp: red -->
|
||||
<path
|
||||
android:fillColor="#B3121B"
|
||||
android:pathData="M0,0h34.29v108h-34.29z" />
|
||||
<path
|
||||
android:fillColor="#B3121B"
|
||||
android:pathData="M72,0h34.29v108h-34.29z" />
|
||||
<!-- Warp: gold overstripe -->
|
||||
<path
|
||||
android:fillColor="#F2C200"
|
||||
android:pathData="M68.57,0h3.43v108h-3.43z" />
|
||||
|
||||
<!-- Weft: red -->
|
||||
<path
|
||||
android:fillColor="#B3121B"
|
||||
android:fillAlpha="0.5"
|
||||
android:pathData="M0,0h108v34.29h-108z" />
|
||||
<path
|
||||
android:fillColor="#B3121B"
|
||||
android:fillAlpha="0.5"
|
||||
android:pathData="M0,72h108v34.29h-108z" />
|
||||
<!-- Weft: black -->
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:fillAlpha="0.5"
|
||||
android:pathData="M0,34.29h108v34.28h-108z" />
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:fillAlpha="0.5"
|
||||
android:pathData="M0,106.29h108v1.71h-108z" />
|
||||
<!-- Weft: gold overstripe -->
|
||||
<path
|
||||
android:fillColor="#F2C200"
|
||||
android:fillAlpha="0.5"
|
||||
android:pathData="M0,68.57h108v3.43h-108z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Deliberately empty. The icon is the cloth itself, so the adaptive icon's
|
||||
foreground layer carries nothing — anything drawn here would sit on top of the
|
||||
tartan and break the "single square of tartan" idea.
|
||||
-->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<!-- A black hole in the center of the icon's safe zone. -->
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
|
||||
Binary file not shown.
@@ -1,17 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Fallback launcher icon for API < 26 (no adaptive-icon support). -->
|
||||
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
|
||||
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
|
||||
at icon scale so one full sett repeat fills the tile. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
android:viewportWidth="84"
|
||||
android:viewportHeight="84">
|
||||
|
||||
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
|
||||
|
||||
<!-- Warp -->
|
||||
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
|
||||
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
|
||||
|
||||
<!-- Weft -->
|
||||
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
|
||||
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
|
||||
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Fallback round launcher icon for API < 26. -->
|
||||
<!-- Fallback launcher icon for API < 26, which has no adaptive-icon support.
|
||||
Same Wallace tartan swatch as drawable/ic_launcher_background.xml, drawn
|
||||
at icon scale so one full sett repeat fills the tile. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="48dp"
|
||||
android:height="48dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#2E7D32"
|
||||
android:pathData="M54,54m-54,0a54,54 0,1 1,108 0a54,54 0,1 1,-108 0" />
|
||||
<path
|
||||
android:fillColor="#111111"
|
||||
android:pathData="M54,54m-26,0a26,26 0,1 1,52 0a26,26 0,1 1,-52 0" />
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M54,54m-20,0a20,20 0,1 1,40 0a20,20 0,1 1,-40 0" />
|
||||
android:viewportWidth="84"
|
||||
android:viewportHeight="84">
|
||||
|
||||
<path android:fillColor="#111111" android:pathData="M0,0h84v84h-84z" />
|
||||
|
||||
<!-- Warp -->
|
||||
<path android:fillColor="#B3121B" android:pathData="M0,0h40v84h-40z" />
|
||||
<path android:fillColor="#F2C200" android:pathData="M80,0h4v84h-4z" />
|
||||
|
||||
<!-- Weft -->
|
||||
<path android:fillColor="#B3121B" android:fillAlpha="0.5" android:pathData="M0,0h84v40h-84z" />
|
||||
<path android:fillColor="#111111" android:fillAlpha="0.5" android:pathData="M0,40h84v40h-84z" />
|
||||
<path android:fillColor="#F2C200" android:fillAlpha="0.5" android:pathData="M0,80h84v4h-84z" />
|
||||
</vector>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1B5E20</color>
|
||||
<color name="ic_launcher_background">#B3121B</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
<!-- Shown for the instant before Compose draws, so launch does not flash
|
||||
white before the tartan splash appears. -->
|
||||
<color name="splash_background">#4A0B10</color>
|
||||
</resources>
|
||||
|
||||
@@ -5,5 +5,6 @@
|
||||
<style name="Theme.ProLibertateGames" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:windowNoTitle">true</item>
|
||||
<item name="android:windowContentOverlay">@null</item>
|
||||
<item name="android:windowBackground">@color/splash_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user