From 2fd9eced63777fff552d004607331fa86d14752d Mon Sep 17 00:00:00 2001 From: Natalie Date: Sun, 26 Apr 2026 16:08:21 -0700 Subject: [PATCH] =?UTF-8?q?feat(@projects/@magic-civilization):=20?= =?UTF-8?q?=E2=9C=A8=20add=20combat=20system=20components?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .project/designs/app/src/App.tsx | 34 +++ .../src/components/combat/CombatantCard.tsx | 190 +++++++++++++ .../src/components/combat/DamageMatrix.tsx | 107 ++++++++ .../app/src/components/combat/HpAfterBar.tsx | 144 ++++++++++ .../app/src/components/combat/KwBanner.tsx | 31 +++ .../src/components/combat/ModifierList.tsx | 61 +++++ .../src/components/combat/ProbabilityBar.tsx | 80 ++++++ .../designs/app/src/components/ui/Button.tsx | 60 +++++ .../designs/app/src/components/ui/Panel.tsx | 44 +++ .../designs/app/src/components/ui/Tabs.tsx | 41 +++ .../designs/app/src/components/ui/Tag.tsx | 40 +++ .project/designs/app/src/data/scenarios.ts | 252 ++++++++++++++++++ .project/designs/app/src/main.tsx | 12 + .../designs/app/src/pages/CombatPreview.tsx | 222 +++++++++++++++ .project/designs/app/src/pages/Index.tsx | 76 ++++++ pnpm-lock.yaml | 65 +++++ pnpm-workspace.yaml | 1 + scripts/run/dev.sh | 4 +- 18 files changed, 1462 insertions(+), 2 deletions(-) create mode 100644 .project/designs/app/src/App.tsx create mode 100644 .project/designs/app/src/components/combat/CombatantCard.tsx create mode 100644 .project/designs/app/src/components/combat/DamageMatrix.tsx create mode 100644 .project/designs/app/src/components/combat/HpAfterBar.tsx create mode 100644 .project/designs/app/src/components/combat/KwBanner.tsx create mode 100644 .project/designs/app/src/components/combat/ModifierList.tsx create mode 100644 .project/designs/app/src/components/combat/ProbabilityBar.tsx create mode 100644 .project/designs/app/src/components/ui/Button.tsx create mode 100644 .project/designs/app/src/components/ui/Panel.tsx create mode 100644 .project/designs/app/src/components/ui/Tabs.tsx create mode 100644 .project/designs/app/src/components/ui/Tag.tsx create mode 100644 .project/designs/app/src/data/scenarios.ts create mode 100644 .project/designs/app/src/main.tsx create mode 100644 .project/designs/app/src/pages/CombatPreview.tsx create mode 100644 .project/designs/app/src/pages/Index.tsx diff --git a/.project/designs/app/src/App.tsx b/.project/designs/app/src/App.tsx new file mode 100644 index 00000000..7dc2eb2e --- /dev/null +++ b/.project/designs/app/src/App.tsx @@ -0,0 +1,34 @@ +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { IndexPage } from "./pages/Index"; +import { CombatPreviewPage } from "./pages/CombatPreview"; + +// Remaining sketch pages are stubs pending port from HTML +function Stub({ name }: { name: string }): React.ReactElement { + return ( +
+
+ {name} +
+
+ Pending port from HTML sketch โ†’ React components +
+
+ ); +} + +export function App(): React.ReactElement { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/.project/designs/app/src/components/combat/CombatantCard.tsx b/.project/designs/app/src/components/combat/CombatantCard.tsx new file mode 100644 index 00000000..ea0eb487 --- /dev/null +++ b/.project/designs/app/src/components/combat/CombatantCard.tsx @@ -0,0 +1,190 @@ +import styled from "styled-components"; +import { t } from "../../theme"; +import { Tag, TagRow } from "../ui/Tag"; +import { ModifierList } from "./ModifierList"; +import type { CombatantState } from "../../data/scenarios"; + +interface Props { + state: CombatantState; + side: "attacker" | "defender"; +} + +const Wrap = styled.div` + display: flex; + flex-direction: column; +`; + +const UnitHeader = styled.div<{ $side: "attacker" | "defender" }>` + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + flex-direction: ${({ $side }) => ($side === "defender" ? "row-reverse" : "row")}; +`; + +const Portrait = styled.div<{ $color: string }>` + width: 56px; + height: 56px; + border-radius: 50%; + border: 2px solid ${({ $color }) => $color}; + display: flex; + align-items: center; + justify-content: center; + font-size: 26px; + background: radial-gradient(ellipse at 40% 40%, #2a1a06, #0e0a17); + flex-shrink: 0; +`; + +const InfoBlock = styled.div<{ $side: "attacker" | "defender" }>` + flex: 1; + text-align: ${({ $side }) => ($side === "defender" ? "right" : "left")}; +`; + +const UnitName = styled.div` + font-family: ${t.font.heading}; + font-size: 16px; + color: ${t.text.title}; + letter-spacing: 0.04em; +`; + +const UnitMeta = styled.div` + font-size: 11px; + color: ${t.text.muted}; + margin-top: 2px; +`; + +const StatGrid = styled.div<{ $side: "attacker" | "defender" }>` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 4px 12px; + margin-bottom: 10px; + text-align: ${({ $side }) => ($side === "defender" ? "right" : "left")}; +`; + +const StatRow = styled.div<{ $side: "attacker" | "defender" }>` + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + flex-direction: ${({ $side }) => ($side === "defender" ? "row-reverse" : "row")}; +`; + +const StatLabel = styled.span` + font-size: 11px; + color: ${t.text.muted}; + text-transform: uppercase; + letter-spacing: 0.06em; + width: 36px; + flex-shrink: 0; +`; + +const ItemChip = styled.span` + display: inline-flex; + align-items: center; + gap: 4px; + background: #0a180a; + border: 1px solid #66b86655; + border-radius: 2px; + padding: 3px 8px; + font-size: 11px; + color: ${t.accent.sage}; + margin-right: 4px; + margin-bottom: 3px; +`; + +const ItemsLabel = styled.div<{ $side: "attacker" | "defender" }>` + font-size: 10px; + color: ${t.text.muted}; + text-transform: uppercase; + letter-spacing: 0.08em; + margin-bottom: 4px; + text-align: ${({ $side }) => ($side === "defender" ? "right" : "left")}; +`; + +const portraits: Record = { + warrior: "โš”", berserker: "๐Ÿช“", ironwarden: "๐Ÿช–", + pikeman: "๐Ÿ›ก", spearmen: "๐Ÿ—ก", cavalry: "๐ŸŽ", + archer: "๐Ÿน", runesmith: "๐Ÿ”ฉ", +}; + +const statColors = { + attack: t.sem.negative, defense: t.accent.science, + hp: t.accent.sage, movement: t.accent.gold, +}; + +function kwVariant(kw: string, unit_keywords: string[]): "kwActive" | "kwDanger" | "kw" { + const danger = ["rage", "anti_cavalry", "first_strike", "trample"]; + const active = ["formation", "shield_wall", "zoc", "fast", "flanking", "ranged", "reach"]; + if (danger.includes(kw)) return "kwDanger"; + if (active.includes(kw)) return "kwActive"; + return "kw"; + void unit_keywords; +} + +export function CombatantCard({ state, side }: Props): React.ReactElement { + const { unit, currentHp, clan, clanColor, promotions, items, modifiers } = state; + + return ( + + + {portraits[unit.id] ?? "โš”"} + + {unit.name} + Tier {unit.tier} ยท {unit.archetype} ยท {unit.id}.json +
{clan}
+
+
+ + + {unit.attackType} + {unit.armor} + {unit.keywords.map(kw => ( + {kw} + ))} + {promotions.map(p => ( + {p} โœ“ + ))} + + + + + ATK + + {unit.attack}{items.some(i => i.effect.includes("melee")) ? `+${items.reduce((s, i) => s + (parseInt(i.effect) || 0), 0)}` : ""} + + + + DEF + {unit.defense} + + + HP + {currentHp}/{unit.hp} + + + MOV + {unit.movement} + + + + {items.length > 0 && ( +
+ Items equipped +
+ {items.map(item => ( + + {item.name} ยท {item.effect} + + ))} +
+
+ )} + + +
+ ); +} diff --git a/.project/designs/app/src/components/combat/DamageMatrix.tsx b/.project/designs/app/src/components/combat/DamageMatrix.tsx new file mode 100644 index 00000000..5b6a67d9 --- /dev/null +++ b/.project/designs/app/src/components/combat/DamageMatrix.tsx @@ -0,0 +1,107 @@ +import styled from "styled-components"; +import { t } from "../../theme"; +import { DAMAGE_MATRIX, type AttackType, type ArmorType } from "../../data/units"; + +interface Props { + highlightAtk?: AttackType; + highlightArmor?: ArmorType; +} + +const ATK_TYPES: AttackType[] = ["blade", "pierce", "crush", "trample", "siege"]; +const ARMOR_TYPES: ArmorType[] = ["unarmored", "light", "medium", "armored", "heavy", "plate", "fortified"]; + +const Wrap = styled.div` + background: ${t.bg.panel}; + border: 1px solid ${t.border.panel}; + border-radius: 4px; + overflow: hidden; + margin-bottom: 16px; +`; + +const Header = styled.div` + background: #1a1228; + padding: 8px 16px; + border-bottom: 1px solid ${t.border.panel}; + font-size: 11px; + color: ${t.text.muted}; + text-transform: uppercase; + letter-spacing: 0.08em; +`; + +const Table = styled.table` + width: 100%; + border-collapse: collapse; + font-size: 11px; + font-family: ${t.font.mono}; +`; + +const Th = styled.th<{ $highlight?: boolean }>` + background: rgba(31,23,51,0.5); + color: ${({ $highlight }) => ($highlight ? t.accent.gold : t.text.muted)}; + padding: 5px 8px; + border: 1px solid ${({ $highlight }) => ($highlight ? t.accent.gold : "#73591f22")}; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; +`; + +function cellStyle(mult: number, activeCell: boolean): string { + let bg = "transparent", color = t.text.secondary; + if (mult >= 1.5) { bg = "#1a0808"; color = t.sem.negative; } + else if (mult >= 1.25) { bg = "#1a1208"; color = t.sem.warning; } + else if (mult <= 0.5) { bg = "#061408"; color = t.accent.sage; } + else if (mult < 1.0) { bg = "#081a08"; color = t.accent.sage; } + if (activeCell) { bg = "#2a1a06"; } + return `background:${bg};color:${color};`; +} + +const Td = styled.td<{ $mult: number; $active: boolean; $colHighlight: boolean }>` + padding: 4px 8px; + text-align: center; + border: ${({ $active }) => ($active ? `2px solid ${t.accent.gold}` : "1px solid #73591f22")}; + font-weight: ${({ $mult }) => ($mult >= 1.5 || $mult <= 0.5 ? "bold" : "normal")}; + ${({ $mult, $active }) => cellStyle($mult, $active)} +`; + +export function DamageMatrix({ highlightAtk, highlightArmor }: Props): React.ReactElement { + return ( + +
+ Damage Matrix โ€” multiplier applied to base attack + {highlightAtk && highlightArmor && ( + <> ยท active: {highlightAtk} vs {highlightArmor} = {Math.round(DAMAGE_MATRIX[highlightAtk][highlightArmor] * 100)}% + )} +
+ + + + + {ARMOR_TYPES.map(armor => ( + + ))} + + + + {ATK_TYPES.map(atk => ( + + + {ARMOR_TYPES.map(armor => { + const mult = DAMAGE_MATRIX[atk][armor]; + return ( + + ); + })} + + ))} + +
Attack \ Armor{armor}
{atk} + {Math.round(mult * 100)}% +
+
+ ); +} diff --git a/.project/designs/app/src/components/combat/HpAfterBar.tsx b/.project/designs/app/src/components/combat/HpAfterBar.tsx new file mode 100644 index 00000000..cfc40a87 --- /dev/null +++ b/.project/designs/app/src/components/combat/HpAfterBar.tsx @@ -0,0 +1,144 @@ +import styled from "styled-components"; +import { t } from "../../theme"; + +interface Props { + label: string; + currentHp: number; + maxHp: number; + dmgMin: number; + dmgMax: number; +} + +const Block = styled.div` + background: rgba(31,23,51,0.6); + border: 1px solid ${t.border.panel}; + border-radius: 3px; + padding: 10px 12px; +`; + +const Label = styled.div` + font-size: 10px; + color: ${t.text.muted}; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 6px; + display: flex; + justify-content: space-between; + align-items: center; +`; + +const DeathBadge = styled.span` + background: #3d0a08; + border: 1px solid ${t.sem.negative}; + border-radius: 2px; + padding: 1px 5px; + font-size: 9px; + color: ${t.sem.negative}; + letter-spacing: 0.06em; +`; + +const Track = styled.div` + height: 20px; + background: #ffffff08; + border-radius: 2px; + position: relative; + overflow: hidden; + border: 1px solid ${t.border.divider}; +`; + +const TrackSegment = styled.div<{ $left: number; $width: number; $color: string }>` + position: absolute; + top: 0; bottom: 0; + left: ${({ $left }) => $left}%; + width: ${({ $width }) => $width}%; + background: ${({ $color }) => $color}; +`; + +const CursorLine = styled.div<{ $pos: number }>` + position: absolute; + top: 0; bottom: 0; + left: ${({ $pos }) => $pos}%; + width: 2px; + background: #ffffff66; +`; + +const ZeroLine = styled.div` + position: absolute; + top: 0; bottom: 0; left: 0; + width: 2px; + background: ${t.sem.negative}; +`; + +const Nums = styled.div` + display: flex; + justify-content: space-between; + font-size: 10px; + font-family: ${t.font.mono}; + margin-top: 4px; +`; + +const DeathProb = styled.div` + font-size: 11px; + color: ${t.sem.negative}; + margin-top: 5px; + font-family: ${t.font.mono}; +`; + +export function HpAfterBar({ label, currentHp, maxHp, dmgMin, dmgMax }: Props): React.ReactElement { + const minRemaining = currentHp - dmgMax; + const maxRemaining = currentHp - dmgMin; + const deathPossible = minRemaining < 0; + + const deathProb = deathPossible + ? Math.round(((dmgMax - currentHp) / (dmgMax - dmgMin)) * 100) + : 0; + + const scale = maxHp; + + // surviving zone: 0 โ†’ clamp(minRemaining, 0, maxHp) + const sureW = Math.max(0, Math.min(minRemaining, maxHp)) / scale * 100; + // possible zone: minRemaining โ†’ maxRemaining (both clamped to [0, maxHp]) + const maybeL = Math.max(0, minRemaining) / scale * 100; + const maybeW = (Math.min(maxRemaining, maxHp) - Math.max(0, minRemaining)) / scale * 100; + const cursorPos = currentHp / scale * 100; + + return ( + + + + {deathPossible && } + {deathPossible && ( + + )} + {!deathPossible && ( + + )} + + + + + {deathPossible ? ( + <> + ๐Ÿ’€ min {minRemaining} (dead) + avg {Math.round(currentHp - (dmgMin + dmgMax) / 2)} + max {maxRemaining} + + ) : ( + <> + min {minRemaining} + avg {Math.round(currentHp - (dmgMin + dmgMax) / 2)} + max {maxRemaining} ยท cannot die + + )} + + {deathPossible && ( + + death probability: ({dmgMax}โˆ’{currentHp})รท({dmgMax}โˆ’{dmgMin}) = {dmgMax - currentHp}/{dmgMax - dmgMin} โ‰ˆ {deathProb}% + + )} + + ); +} diff --git a/.project/designs/app/src/components/combat/KwBanner.tsx b/.project/designs/app/src/components/combat/KwBanner.tsx new file mode 100644 index 00000000..dfa2a47f --- /dev/null +++ b/.project/designs/app/src/components/combat/KwBanner.tsx @@ -0,0 +1,31 @@ +import styled from "styled-components"; +import { t } from "../../theme"; +import type { Banner } from "../../data/scenarios"; + +const styles = { + warn: { bg: "#3d2000", border: "#e6993355", color: t.sem.warning }, + danger: { bg: "#3d0a08", border: "#d9594055", color: t.sem.negative }, + good: { bg: "#0a1a08", border: "#66b86655", color: t.accent.sage }, + info: { bg: "#0a1428", border: "#66bfff44", color: t.accent.science }, +}; + +const El = styled.div<{ $type: Banner["type"] }>` + margin: 4px 0; + padding: 8px 14px; + border-radius: 3px; + font-size: 12px; + display: flex; + align-items: center; + gap: 8px; + background: ${({ $type }) => styles[$type].bg}; + border: 1px solid ${({ $type }) => styles[$type].border}; + color: ${({ $type }) => styles[$type].color}; +`; + +interface Props { + banner: Banner; +} + +export function KwBanner({ banner }: Props): React.ReactElement { + return {banner.text}; +} diff --git a/.project/designs/app/src/components/combat/ModifierList.tsx b/.project/designs/app/src/components/combat/ModifierList.tsx new file mode 100644 index 00000000..2d3e45f6 --- /dev/null +++ b/.project/designs/app/src/components/combat/ModifierList.tsx @@ -0,0 +1,61 @@ +import styled from "styled-components"; +import { t } from "../../theme"; +import type { Modifier } from "../../data/scenarios"; + +interface Props { + title: string; + mods: Modifier[]; + align?: "left" | "right"; +} + +const Wrap = styled.div` + background: rgba(10,8,16,0.5); + border: 1px solid ${t.border.divider}; + border-radius: 3px; + padding: 8px 10px; + font-size: 11px; +`; + +const Title = styled.div` + color: ${t.text.muted}; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.08em; + margin-bottom: 5px; +`; + +const Row = styled.div<{ $align: "left" | "right"; $type: Modifier["type"] }>` + display: flex; + justify-content: space-between; + flex-direction: ${({ $align }) => ($align === "right" ? "row-reverse" : "row")}; + padding: 2px 0; + color: ${t.text.secondary}; + border-bottom: 1px solid #73591f18; + font-weight: ${({ $type }) => ($type === "final" ? "bold" : "normal")}; + color: ${({ $type }) => ($type === "final" ? t.text.primary : t.text.secondary)}; + + &:last-child { border-bottom: none; } +`; + +const Val = styled.span<{ $type: Modifier["type"] }>` + font-family: ${t.font.mono}; + color: ${({ $type }) => + $type === "multiply" ? t.sem.warning : + $type === "add" ? t.accent.sage : + $type === "final" ? t.accent.gold : + t.text.secondary}; +`; + +export function ModifierList({ title, mods, align = "left" }: Props): React.ReactElement { + return ( + + {title} + {mods.map((m, i) => ( + + {m.label} + {m.value} + + ))} + + ); +} diff --git a/.project/designs/app/src/components/combat/ProbabilityBar.tsx b/.project/designs/app/src/components/combat/ProbabilityBar.tsx new file mode 100644 index 00000000..c5ff0e43 --- /dev/null +++ b/.project/designs/app/src/components/combat/ProbabilityBar.tsx @@ -0,0 +1,80 @@ +import styled from "styled-components"; +import { t } from "../../theme"; + +interface Props { + effAtk: number; + effDef: number; + atkLabel?: string; + defLabel?: string; +} + +const Wrap = styled.div` + margin-bottom: 14px; +`; + +const Formula = styled.div` + font-size: 11px; + color: ${t.text.muted}; + font-family: ${t.font.mono}; + text-align: center; + margin-bottom: 6px; +`; + +const Bar = styled.div` + height: 28px; + border-radius: 3px; + overflow: hidden; + display: flex; +`; + +const AtkSide = styled.div<{ $pct: number }>` + width: ${({ $pct }) => $pct}%; + background: linear-gradient(90deg, #d9594055, #d9594088); + border-right: 2px solid ${t.sem.negative}; + display: flex; + align-items: center; + padding: 0 10px; + font-size: 13px; + font-weight: bold; + color: ${t.sem.negative}; +`; + +const DefSide = styled.div` + flex: 1; + background: linear-gradient(90deg, #66b86622, #66b86655); + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0 10px; + font-size: 13px; + font-weight: bold; + color: ${t.accent.sage}; +`; + +const Labels = styled.div` + display: flex; + justify-content: space-between; + font-size: 10px; + color: ${t.text.muted}; + margin-top: 3px; +`; + +export function ProbabilityBar({ effAtk, effDef, atkLabel = "Attacker wins", defLabel = "Defender survives" }: Props): React.ReactElement { + const pct = Math.round((effAtk / (effAtk + effDef)) * 100); + return ( + + + win% = {effAtk.toFixed(1)} / ({effAtk.toFixed(1)} + {effDef.toFixed(1)}) ={" "} + {pct}% + + + {pct}% + {100 - pct}% + + + {atkLabel} + {defLabel} + + + ); +} diff --git a/.project/designs/app/src/components/ui/Button.tsx b/.project/designs/app/src/components/ui/Button.tsx new file mode 100644 index 00000000..f026b7e0 --- /dev/null +++ b/.project/designs/app/src/components/ui/Button.tsx @@ -0,0 +1,60 @@ +import styled from "styled-components"; +import { t } from "../../theme"; + +export const Btn = styled.button` + font-family: ${t.font.body}; + font-size: 15px; + font-weight: 700; + color: ${t.text.btn}; + background: ${t.bg.btnNormal}; + border: 1px solid ${t.border.panel}; + border-radius: ${t.radius.btn}; + padding: 8px 18px; + cursor: pointer; + transition: all 150ms ease; + letter-spacing: 0.03em; + + &:hover { + background: ${t.bg.btnHover}; + border-color: ${t.accent.goldBright}; + color: ${t.text.btnHover}; + } +`; + +export const BtnAttack = styled(Btn)` + flex: 1; + font-family: ${t.font.heading}; + font-size: 18px; + color: ${t.sem.negative}; + background: #3d0f08; + border: 2px solid ${t.sem.negative}; + padding: 10px; + text-align: center; + letter-spacing: 0.06em; + + &:hover { background: #5a1510; } +`; + +export const BtnAttackRisky = styled(BtnAttack)` + background: #5a0808; + border-color: #ff6644; +`; + +export const BtnCancel = styled(Btn)` + color: ${t.text.muted}; + padding: 10px 20px; +`; + +export const BtnEndTurn = styled(Btn)` + font-family: ${t.font.heading}; + font-size: 18px; + color: ${t.text.title}; + background: #2a1a06; + border: 2px solid ${t.accent.gold}; + padding: 10px 32px; + letter-spacing: 0.06em; + width: 200px; + text-align: center; + + &:hover { background: #3d2608; border-color: ${t.accent.goldBright}; } +`; diff --git a/.project/designs/app/src/components/ui/Panel.tsx b/.project/designs/app/src/components/ui/Panel.tsx new file mode 100644 index 00000000..af1d5f30 --- /dev/null +++ b/.project/designs/app/src/components/ui/Panel.tsx @@ -0,0 +1,44 @@ +import styled from "styled-components"; +import { t } from "../../theme"; + +export const Panel = styled.div` + background: ${t.bg.panel}; + border: 1px solid ${t.border.panel}; + border-radius: ${t.radius.panel}; + overflow: hidden; +`; + +export const PanelHeader = styled.div` + background: #1a1228; + border-bottom: 2px solid ${t.border.panel}; + padding: 14px 20px; +`; + +export const PanelTitle = styled.div` + font-family: ${t.font.heading}; + font-size: 20px; + color: ${t.text.title}; + letter-spacing: 0.04em; +`; + +export const PanelSub = styled.div` + font-size: 11px; + color: ${t.text.secondary}; + margin-top: 2px; +`; + +export const SectionTitle = styled.div` + font-family: ${t.font.heading}; + font-size: 16px; + color: ${t.accent.gold}; + letter-spacing: 0.05em; + padding-bottom: 6px; + border-bottom: 1px solid ${t.border.divider}; + margin-bottom: 12px; +`; + +export const Divider = styled.hr` + border: none; + border-top: 1px solid ${t.border.divider}; + margin: 10px 0; +`; diff --git a/.project/designs/app/src/components/ui/Tabs.tsx b/.project/designs/app/src/components/ui/Tabs.tsx new file mode 100644 index 00000000..674dbd76 --- /dev/null +++ b/.project/designs/app/src/components/ui/Tabs.tsx @@ -0,0 +1,41 @@ +import styled from "styled-components"; +import { t } from "../../theme"; + +export const TabBar = styled.div` + display: flex; + border-bottom: 1px solid ${t.border.panel}; + margin-bottom: 20px; +`; + +const TabEl = styled.button<{ $active: boolean }>` + font-family: ${t.font.body}; + font-size: 12px; + font-weight: bold; + padding: 9px 18px; + color: ${({ $active }) => ($active ? t.text.title : t.text.muted)}; + background: transparent; + border: none; + border-bottom: 2px solid ${({ $active }) => ($active ? t.accent.gold : "transparent")}; + margin-bottom: -1px; + cursor: pointer; + letter-spacing: 0.03em; + transition: color 150ms ease; +`; + +interface Props { + tabs: string[]; + active: number; + onChange: (i: number) => void; +} + +export function Tabs({ tabs, active, onChange }: Props): React.ReactElement { + return ( + + {tabs.map((label, i) => ( + onChange(i)}> + {label} + + ))} + + ); +} diff --git a/.project/designs/app/src/components/ui/Tag.tsx b/.project/designs/app/src/components/ui/Tag.tsx new file mode 100644 index 00000000..84383817 --- /dev/null +++ b/.project/designs/app/src/components/ui/Tag.tsx @@ -0,0 +1,40 @@ +import styled from "styled-components"; +import { t } from "../../theme"; + +type TagVariant = "atk" | "arm" | "kw" | "kwActive" | "kwDanger" | "item"; + +interface Props { + variant: TagVariant; + children: React.ReactNode; +} + +const variantStyles: Record = { + atk: `background:#3d0f08;border-color:#d9594066;color:#d9594099;`, + arm: `background:#0a1428;border-color:#66bfff44;color:#66bfff88;`, + kw: `background:#1a1208;border-color:#d9a02066;color:#d9a02099;`, + kwActive: `background:#2a1a06;border-color:${t.accent.gold};color:${t.accent.gold};`, + kwDanger: `background:#3d0f08;border-color:#d9594066;color:#d95940bb;`, + item: `background:#0a180a;border-color:#66b86655;color:#66b866aa;`, +}; + +const TagEl = styled.span<{ $variant: TagVariant }>` + font-size: 10px; + padding: 2px 6px; + border-radius: 2px; + font-family: ${t.font.mono}; + font-weight: bold; + border: 1px solid; + ${({ $variant }) => variantStyles[$variant]} +`; + +export function Tag({ variant, children }: Props): React.ReactElement { + return {children}; +} + +export const TagRow = styled.div<{ $align?: "end" }>` + display: flex; + gap: 4px; + flex-wrap: wrap; + justify-content: ${({ $align }) => ($align === "end" ? "flex-end" : "flex-start")}; + margin-bottom: 8px; +`; diff --git a/.project/designs/app/src/data/scenarios.ts b/.project/designs/app/src/data/scenarios.ts new file mode 100644 index 00000000..c6f9f027 --- /dev/null +++ b/.project/designs/app/src/data/scenarios.ts @@ -0,0 +1,252 @@ +import { UNITS, matrixMultiplier, type Unit } from "./units"; + +export interface Modifier { + label: string; + value: string; + type: "base" | "multiply" | "add" | "final"; +} + +export interface EquippedItem { + name: string; + effect: string; + source: string; // e.g. "Forge ยท 100โš’ ยท 2ร— mithril_vein" +} + +export interface CombatantState { + unit: Unit; + currentHp: number; + clan: string; + clanColor: string; + promotions: string[]; + items: EquippedItem[]; + modifiers: Modifier[]; + effectiveStat: number; // effective attack (attacker) or effective defense (defender) +} + +export interface DamageRange { + min: number; + avg: number; + max: number; +} + +export interface Banner { + type: "warn" | "danger" | "good" | "info"; + text: string; +} + +export interface CombatScenario { + id: string; + title: string; + subtitle: string; + terrain: string; + attacker: CombatantState; + defender: CombatantState; + damageToDefender: DamageRange; + damageToAttacker: DamageRange; + banners: Banner[]; + matrixLabel: string; + matrixResultLabel: string; + matrixResultClass: "neutral" | "good" | "great" | "bad"; +} + +// โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +function pct(n: number): string { + return `${Math.round(n * 100)}%`; +} + +function fmtMult(n: number): string { + return `ร—${n.toFixed(2)}`; +} + +// โ”€โ”€ Scenario data โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +const warrior = UNITS.warrior; +const berserker = UNITS.berserker; +const ironwarden = UNITS.ironwarden; +const pikeman = UNITS.pikeman; +const spearmen = UNITS.spearmen; +const cavalry = UNITS.cavalry; + +// Scenario 0: Warrior (68 hp) vs Pikeman (100 hp) +// blade vs light = 1.25; Shock I +15% open terrain +// eff atk = 14 * 1.25 * 1.15 = 20.1 +// counter: pierce vs light = 1.00; no promotions โ†’ eff = 10 +// dmg to pikeman: base 20.1, range ยฑ20% โ†’ 16โ€“24 +// counter to warrior: base 10, range ยฑ20% โ†’ 8โ€“12 +export const s0: CombatScenario = { + id: "s0", + title: "Warrior attacks Pikeman", + subtitle: "Plains (2,4) ยท Melee ยท Blade vs Light ยท warrior.json / pikeman.json", + terrain: "Plains", + attacker: { + unit: warrior, currentHp: 68, clan: "Stoneguard (You)", clanColor: "#6699ff", + promotions: ["Shock I"], + items: [], + modifiers: [ + { label: "Base attack", value: "14", type: "base" }, + { label: `Blade vs Light (${pct(matrixMultiplier("blade", "light"))})`, value: fmtMult(matrixMultiplier("blade","light")), type: "multiply" }, + { label: "Shock I ยท open terrain", value: "+15%", type: "add" }, + { label: "Effective attack", value: "20.1", type: "final" }, + ], + effectiveStat: 20.1, + }, + defender: { + unit: pikeman, currentHp: 100, clan: "Emberfall Clan", clanColor: "#ff8888", + promotions: [], + items: [], + modifiers: [ + { label: "Base defense", value: "14", type: "base" }, + { label: "Plains terrain", value: "+0%", type: "add" }, + { label: "No promotions", value: "โ€”", type: "add" }, + { label: "Effective defense", value: "14.0", type: "final" }, + ], + effectiveStat: 14.0, + }, + damageToDefender: { min: 16, avg: 20, max: 24 }, + damageToAttacker: { min: 8, avg: 10, max: 12 }, + banners: [], + matrixLabel: "blade vs light", + matrixResultLabel: "Favourable matchup (125%)", + matrixResultClass: "good", +}; + +// Scenario 1: Berserker with RAGE (90 hp) vs Warrior (80 hp) +// blade vs light = 1.25; RAGE +25% +// eff atk = 20 * 1.25 * 1.25 = 31.25 +// counter: 14 * 1.25 = 17.5 +export const s1: CombatScenario = { + id: "s1", + title: "Berserker (RAGE active) attacks Warrior", + subtitle: "Plains ยท Blade vs Light ยท berserker.json / warrior.json", + terrain: "Plains", + attacker: { + unit: berserker, currentHp: 90, clan: "Stoneguard (You)", clanColor: "#6699ff", + promotions: ["Drill I"], + items: [], + modifiers: [ + { label: "Base attack", value: "20", type: "base" }, + { label: `Blade vs Light (${pct(matrixMultiplier("blade","light"))})`, value: fmtMult(matrixMultiplier("blade","light")), type: "multiply" }, + { label: "RAGE keyword (kill last turn)", value: "+25%", type: "add" }, + { label: "Effective attack", value: "31.3", type: "final" }, + ], + effectiveStat: 31.3, + }, + defender: { + unit: warrior, currentHp: 80, clan: "Emberfall Clan", clanColor: "#ff8888", + promotions: [], + items: [], + modifiers: [ + { label: "Base defense", value: "8", type: "base" }, + { label: "No promotions", value: "โ€”", type: "add" }, + { label: "Effective defense", value: "8.0", type: "final" }, + ], + effectiveStat: 8.0, + }, + damageToDefender: { min: 25, avg: 31, max: 38 }, + damageToAttacker: { min: 10, avg: 14, max: 18 }, + banners: [ + { type: "danger", text: "๐Ÿ”ฅ RAGE active โ€” killed a unit last turn. +25% attack this turn only. Resets after." }, + { type: "warn", text: "๐Ÿ›ก no_shield โ€” Berserker cannot benefit from Cover promotions (ranged defense disabled)." }, + ], + matrixLabel: "blade vs light + rage", + matrixResultLabel: "Devastating (125% + rage +25%)", + matrixResultClass: "great", +}; + +// Scenario 2: Ironwarden (items) vs Berserker WOUNDED 45/90 +// Master Blade +6 โ†’ atk 28; blade vs light 125%; Shock II +30% open โ†’ 28*1.25*1.30 = 45.5 +// counter: berserker blade vs heavy = 0.75 โ†’ 20*0.75 = 15.0 +// Berserker at 45 hp; takes 36โ€“57 โ†’ min 45-57=-12 DEAD, max 45-36=9 +export const s2: CombatScenario = { + id: "s2", + title: "Ironwarden (items) vs Berserker (wounded)", + subtitle: "Hills ยท Blade vs Light ยท ironwarden.json / berserker.json ยท items equipped", + terrain: "Hills", + attacker: { + unit: ironwarden, currentHp: 110, clan: "Stoneguard (You)", clanColor: "#6699ff", + promotions: ["Shock I", "Shock II"], + items: [ + { name: "Master Blade", effect: "+6 melee", source: "Forge ยท 100โš’ ยท 2ร— mithril_vein ยท tech: high_smithing" }, + { name: "Tower Shield", effect: "+3 def / +2 vs ranged", source: "Smithy ยท 50โš’ ยท 2ร— iron_ore ยท tech: tactics" }, + ], + modifiers: [ + { label: "Base attack", value: "22", type: "base" }, + { label: "Master Blade (item)", value: "+6", type: "add" }, + { label: `Blade vs Light (${pct(matrixMultiplier("blade","light"))})`, value: fmtMult(matrixMultiplier("blade","light")), type: "multiply" }, + { label: "Shock II ยท hills terrain (no open bonus)", value: "โ€”", type: "add" }, + { label: "Effective attack", value: "45.5", type: "final" }, + ], + effectiveStat: 45.5, + }, + defender: { + unit: berserker, currentHp: 45, clan: "Emberfall Clan", clanColor: "#ff8888", + promotions: [], + items: [], + modifiers: [ + { label: "Base attack", value: "20", type: "base" }, + { label: `Blade vs Heavy (${pct(matrixMultiplier("blade","heavy"))})`, value: fmtMult(matrixMultiplier("blade","heavy")), type: "multiply" }, + { label: "No promotions", value: "โ€”", type: "add" }, + { label: "Effective counter", value: "15.0", type: "final" }, + ], + effectiveStat: 6.0, // defense stat used for win% denominator + }, + damageToDefender: { min: 36, avg: 45, max: 57 }, + damageToAttacker: { min: 10, avg: 15, max: 20 }, + banners: [ + { type: "good", text: "โš” Master Blade โ€” crafted at Forge (100โš’, 2ร— mithril_vein). +6 melee flat before matrix multiply." }, + { type: "info", text: "๐Ÿ›ก Tower Shield โ€” +3 defense flat, +2 additional vs ranged attacks. Requires tech: tactics." }, + { type: "warn", text: "โš™ Blade vs Heavy = 75% โ€” Berserker counter is heavily penalised. Use Pierce or Crush vs heavy armour." }, + ], + matrixLabel: "blade vs light (atk) / blade vs heavy (counter)", + matrixResultLabel: "45.5 effective โ€” 57 max damage", + matrixResultClass: "great", +}; + +// Scenario 3: Cavalry (wounded 28/70) vs Spearmen (60 hp) +// Cavalry: blade vs medium = 1.00; flanking +15% โ†’ 16*1.00*1.15 = 18.4 +// Spearmen counter: pierce vs light = 1.25; anti_cavalry +100% โ†’ 8*1.25*2.00 = 20 +// Cavalry at 28 hp; takes 18โ€“32 โ†’ 28-32=-4 DEAD, 28-18=10 max survive +// death prob = (32-28)/(32-18) = 4/14 โ‰ˆ 29% +export const s3: CombatScenario = { + id: "s3", + title: "Cavalry (wounded) vs Spearmen", + subtitle: "Plains ยท Blade vs Medium ยท cavalry.json / spearmen.json ยท hard counter", + terrain: "Plains", + attacker: { + unit: cavalry, currentHp: 28, clan: "Stoneguard (You)", clanColor: "#6699ff", + promotions: [], + items: [], + modifiers: [ + { label: "Base attack", value: "16", type: "base" }, + { label: `Blade vs Medium (${pct(matrixMultiplier("blade","medium"))})`, value: fmtMult(matrixMultiplier("blade","medium")), type: "multiply" }, + { label: "flanking keyword", value: "+15%", type: "add" }, + { label: "Effective attack", value: "18.4", type: "final" }, + ], + effectiveStat: 18.4, + }, + defender: { + unit: spearmen, currentHp: 60, clan: "Emberfall Clan", clanColor: "#ff8888", + promotions: [], + items: [], + modifiers: [ + { label: "Base attack", value: "8", type: "base" }, + { label: `Pierce vs Light (${pct(matrixMultiplier("pierce","light"))})`, value: fmtMult(matrixMultiplier("pierce","light")), type: "multiply" }, + { label: "anti_cavalry keyword", value: "+100%", type: "add" }, + { label: "Effective counter", value: "20.0", type: "final" }, + ], + effectiveStat: 20.0, + }, + damageToDefender: { min: 12, avg: 18, max: 23 }, + damageToAttacker: { min: 18, avg: 25, max: 32 }, + banners: [ + { type: "danger", text: "โš  HARD COUNTER โ€” anti_cavalry keyword gives Spearmen +100% vs Cavalry. Cavalry at 28 HP can die this turn." }, + { type: "info", text: "๐Ÿ—ก reach keyword โ€” Spearmen strike flying attackers and can First Strike against charging melee." }, + { type: "warn", text: "๐ŸŒฟ Open terrain caveat: in Forest/City, Cavalry loses its advantage. Cavalry should never charge Anti-Cavalry." }, + ], + matrixLabel: "pierce vs light + anti_cavalry ร—200%", + matrixResultLabel: "Spearmen = hard counter", + matrixResultClass: "great", +}; + +export const SCENARIOS: CombatScenario[] = [s0, s1, s2, s3]; diff --git a/.project/designs/app/src/main.tsx b/.project/designs/app/src/main.tsx new file mode 100644 index 00000000..854e9d3c --- /dev/null +++ b/.project/designs/app/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; + +const root = document.getElementById("root"); +if (!root) throw new Error("No #root element"); + +createRoot(root).render( + + + +); diff --git a/.project/designs/app/src/pages/CombatPreview.tsx b/.project/designs/app/src/pages/CombatPreview.tsx new file mode 100644 index 00000000..7c4e2528 --- /dev/null +++ b/.project/designs/app/src/pages/CombatPreview.tsx @@ -0,0 +1,222 @@ +import { useState } from "react"; +import styled from "styled-components"; +import { t } from "../theme"; +import { SCENARIOS } from "../data/scenarios"; +import { Tabs } from "../components/ui/Tabs"; +import { Panel, PanelHeader, PanelTitle, PanelSub } from "../components/ui/Panel"; +import { BtnAttack, BtnAttackRisky, BtnCancel } from "../components/ui/Button"; +import { DamageMatrix } from "../components/combat/DamageMatrix"; +import { CombatantCard } from "../components/combat/CombatantCard"; +import { ProbabilityBar } from "../components/combat/ProbabilityBar"; +import { HpAfterBar } from "../components/combat/HpAfterBar"; +import { KwBanner } from "../components/combat/KwBanner"; + +const Page = styled.div` + max-width: 840px; + margin: 0 auto; + padding: 32px 16px; +`; + +const PageTitle = styled.div` + font-family: ${t.font.heading}; + font-size: 28px; + color: ${t.text.title}; + margin-bottom: 4px; +`; + +const PageSub = styled.div` + font-size: 12px; + color: ${t.text.muted}; + font-family: ${t.font.mono}; + margin-bottom: 24px; +`; + +const SourceBadge = styled.span` + display: inline-block; + background: #0a1a0a; + border: 1px solid #66b86644; + border-radius: 2px; + padding: 2px 7px; + font-size: 10px; + color: ${t.accent.sage}; + font-family: ${t.font.mono}; + margin-right: 4px; +`; + +const VsGrid = styled.div` + display: grid; + grid-template-columns: 1fr 48px 1fr; + padding: 20px 20px 0; +`; + +const VsCenter = styled.div` + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 20px; +`; + +const VsBadge = styled.div` + font-family: ${t.font.heading}; + font-size: 22px; + color: ${t.sem.negative}; + width: 40px; + height: 40px; + border: 1px solid #d9594044; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +`; + +const MatrixRow = styled.div` + margin: 12px 20px 0; + padding: 10px 14px; + background: rgba(31,23,51,0.7); + border: 1px solid ${t.border.panel}; + border-radius: 3px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + font-size: 12px; +`; + +const OutcomeSection = styled.div` + padding: 16px 20px; + border-top: 1px solid ${t.border.divider}; + margin-top: 16px; +`; + +const OutcomeTitle = styled.div` + font-size: 11px; + color: ${t.text.muted}; + text-transform: uppercase; + letter-spacing: 0.1em; + margin-bottom: 12px; + text-align: center; +`; + +const HpGrid = styled.div` + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 14px; +`; + +const ActionRow = styled.div` + display: flex; + gap: 10px; + padding: 14px 20px; + border-top: 1px solid ${t.border.divider}; + background: rgba(10,8,16,0.5); +`; + +const BannerWrap = styled.div` + padding: 0 20px; + margin-top: 8px; + display: flex; + flex-direction: column; + gap: 4px; +`; + +const resultColors = { + neutral: t.text.secondary, + good: t.sem.warning, + great: t.sem.negative, + bad: t.accent.sage, +}; + +const SCENARIO_TABS = ["โš” Warrior vs Pikeman", "๐Ÿช“ Berserker RAGE", "๐Ÿ›ก Items", "๐ŸŽ Trample Counter"]; + +export function CombatPreviewPage(): React.ReactElement { + const [active, setActive] = useState(0); + const s = SCENARIOS[active]; + + const isRisky = s.attacker.effectiveStat < s.defender.effectiveStat; + + return ( + + Combat Preview + + warrior.json + berserker.json + pikeman.json + COMBAT_SYSTEM.md + promotions.json + items/ + โ€” real unit stats, real promotions, real damage matrix + + + + + + + + +
+ {s.title} + {s.subtitle} +
+
+ + + + VS + + + + {s.banners.length > 0 && ( + + {s.banners.map((b, i) => )} + + )} + + + Damage type + {s.matrixLabel} + + {s.matrixResultLabel} + + + + + Predicted Outcome + + + + + + + + + + + {isRisky + ? โš  Risky โ€” Confirm Anyway + : โš” Confirm Attack + } + {isRisky ? "Cancel (Recommended)" : "Cancel"} + +
+
+ ); +} diff --git a/.project/designs/app/src/pages/Index.tsx b/.project/designs/app/src/pages/Index.tsx new file mode 100644 index 00000000..fe5cd60b --- /dev/null +++ b/.project/designs/app/src/pages/Index.tsx @@ -0,0 +1,76 @@ +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import { t } from "../theme"; + +const Page = styled.div` + max-width: 600px; + margin: 0 auto; + padding: 48px 32px; +`; + +const Title = styled.h1` + font-family: ${t.font.heading}; + font-size: 36px; + color: ${t.text.title}; + margin-bottom: 6px; +`; + +const Sub = styled.p` + color: ${t.text.muted}; + font-size: 13px; + margin-bottom: 32px; + font-family: ${t.font.mono}; +`; + +const NavList = styled.ul` + list-style: none; + padding: 0; +`; + +const NavItem = styled.li` + margin-bottom: 10px; +`; + +const NavLink = styled(Link)` + display: block; + color: ${t.accent.gold}; + text-decoration: none; + font-size: 15px; + padding: 10px 16px; + background: ${t.bg.panel}; + border: 1px solid ${t.border.panel}; + border-radius: 3px; + transition: all 150ms ease; + + &:hover { + background: ${t.bg.btnHover}; + border-color: ${t.accent.goldBright}; + color: ${t.text.btnHover}; + } +`; + +const routes = [ + { path: "/gallery", label: "๐Ÿ–ผ Design Gallery โ€” colors, type, components" }, + { path: "/hud", label: "๐Ÿ—บ World Map HUD โ€” top bar, unit panel, minimap" }, + { path: "/city", label: "๐Ÿ› City Screen โ€” production queue, buildings, citizens" }, + { path: "/menu", label: "โš’ Main Menu โ€” atmospheric title screen" }, + { path: "/tech", label: "โš— Tech Tree โ€” node graph, era bands, research" }, + { path: "/combat", label: "โš” Combat Preview โ€” real stats, damage matrix, HP bars" }, + { path: "/promotion", label: "โ˜… Promotion Picker โ€” grid, lock states, prereqs" }, +]; + +export function IndexPage(): React.ReactElement { + return ( + + Age of Dwarves + Design System ยท .project/designs/app ยท React + Vite + styled-components + + {routes.map(r => ( + + {r.label} + + ))} + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c497417..a0735003 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,40 @@ pnpmfileChecksum: sha256-pOgi3Q/PioTN3OH46Bs1frJvlmD0aNz/ZYybp7xmlws= importers: + .project/designs/app: + dependencies: + react: + specifier: ^19.1.0 + version: 19.2.5 + react-dom: + specifier: ^19.1.0 + version: 19.2.5(react@19.2.5) + react-router-dom: + specifier: ^7.5.3 + version: 7.14.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + styled-components: + specifier: ^6.1.18 + version: 6.4.0(css-to-react-native@3.2.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + devDependencies: + '@types/react': + specifier: ^19.1.2 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.1.2 + version: 19.2.3(@types/react@19.2.14) + '@types/styled-components': + specifier: ^5.1.34 + version: 5.1.36 + '@vitejs/plugin-react': + specifier: ^4.4.1 + version: 4.7.0(vite@6.4.2(@types/node@25.6.0)(tsx@4.21.0)) + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vite: + specifier: ^6.3.3 + version: 6.4.2(@types/node@25.6.0)(tsx@4.21.0) + public/games/age-of-dwarves/guide: dependencies: '@lilith/ui-feedback': @@ -1236,6 +1270,11 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/hoist-non-react-statics@3.3.7': + resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==} + peerDependencies: + '@types/react': '*' + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1262,6 +1301,9 @@ packages: '@types/stats.js@0.17.4': resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + '@types/styled-components@5.1.36': + resolution: {integrity: sha512-pGMRNY5G2rNDKEv2DOiFYa7Ft1r0jrhmgBwHhOMzPTgCjO76bCot0/4uEfqj7K0Jf1KdQmDtAuaDk9EAs9foSw==} + '@types/stylis@4.2.7': resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==} @@ -2058,6 +2100,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -2611,6 +2656,9 @@ packages: peerDependencies: react: ^19.2.5 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-markdown@10.1.0: resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} peerDependencies: @@ -3861,6 +3909,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/hoist-non-react-statics@3.3.7(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + hoist-non-react-statics: 3.3.2 + '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} @@ -3885,6 +3938,12 @@ snapshots: '@types/stats.js@0.17.4': {} + '@types/styled-components@5.1.36': + dependencies: + '@types/hoist-non-react-statics': 3.3.7(@types/react@19.2.14) + '@types/react': 19.2.14 + csstype: 3.2.3 + '@types/stylis@4.2.7': {} '@types/three@0.183.1': @@ -4840,6 +4899,10 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hoist-non-react-statics@3.3.2: + dependencies: + react-is: 16.13.1 + html-url-attributes@3.0.1: {} ignore@5.3.2: {} @@ -5603,6 +5666,8 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 + react-is@16.13.1: {} + react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.5): dependencies: '@types/hast': 3.0.4 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c6b907ed..ad0b5bd9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,4 @@ packages: - src/simulator - src/packages/* - public/games/*/guide + - .project/designs/app diff --git a/scripts/run/dev.sh b/scripts/run/dev.sh index 9e2c1871..b5c453b9 100644 --- a/scripts/run/dev.sh +++ b/scripts/run/dev.sh @@ -52,9 +52,9 @@ cmd_guide() { cmd_designs() { local port="${1:-7777}" - echo -e "${BLUE}Starting design viewer (port ${port})...${NC}" + echo -e "${BLUE}Starting design app (port ${port})...${NC}" echo -e "${BLUE} http://localhost:${port}${NC}" - node "$REPO_ROOT/.project/designs/serve.js" "$port" + pnpm --prefix "$REPO_ROOT/.project/designs/app" run dev -- --port "$port" } cmd_screenshot() {