feat: surface recipe hints on undiscovered codex slots (A-5)

Each tier section now shows up to 6 undiscovered silhouettes after the owned
cards, sorted by craftable-now > known-ingredients > unknown. Hint is the
two-ingredient emoji pair, with "?" when an ingredient has not been found.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 04:06:54 +09:00
parent 744ccbf434
commit 56664b56d3

View File

@@ -795,6 +795,14 @@ const undiscoveredNameStyle = css`
text-align: center;
`;
const undiscoveredHintStyle = (craftable: boolean) => css`
font-size: 9px;
font-weight: 800;
color: ${craftable ? adaptive.blue500 : adaptive.grey400};
letter-spacing: 0.5px;
margin-top: 2px;
`;
const elementCountStyle = css`
font-size: 11px;
color: ${adaptive.blue500};
@@ -1115,6 +1123,34 @@ const TIER_LABELS: Record<number, string> = {
type ElementData = (typeof elementsData)[number];
const elementMap = Object.fromEntries(elementsData.map((el) => [el.id, el]));
interface RecipeHint {
display: string;
craftableNow: boolean;
}
function getRecipeHintForElement(
elementId: string,
discoveredIds: string[],
ownedCounts: Record<string, number>
): RecipeHint | null {
const recipe = recipesData.find((r) => r.result === elementId);
if (!recipe) return null;
const [aId, bId] = recipe.ingredients;
const aDiscovered = discoveredIds.includes(aId);
const bDiscovered = discoveredIds.includes(bId);
const aSym = aDiscovered ? (elementMap[aId]?.emoji ?? '?') : '?';
const bSym = bDiscovered ? (elementMap[bId]?.emoji ?? '?') : '?';
const sameElement = aId === bId;
const hasA = (ownedCounts[aId] ?? 0) >= (sameElement ? 2 : 1);
const hasB = sameElement ? hasA : (ownedCounts[bId] ?? 0) >= 1;
return {
display: `${aSym} + ${bSym}`,
craftableNow: aDiscovered && bDiscovered && hasA && hasB,
};
}
interface ActivityEntry {
id: number;
text: string;
@@ -1267,6 +1303,8 @@ interface ElementCardProps {
onSelect: (el: ElementData) => void;
isAutomated?: boolean;
isTutorialTarget?: boolean;
mood?: Mood;
hint?: RecipeHint | null;
}
const ElementCard = memo(function ElementCard({
@@ -1277,6 +1315,8 @@ const ElementCard = memo(function ElementCard({
onSelect,
isAutomated = true,
isTutorialTarget,
mood,
hint,
}: ElementCardProps) {
const spawnRate = calcSpawnRate(el.id, level);
@@ -1284,9 +1324,20 @@ const ElementCard = memo(function ElementCard({
return (
<div css={undiscoveredCardStyle}>
<div css={spriteWrapStyle}>
<CharacterSprite elementId={el.id} elementColor={el.color} tier={el.tier} size={56} state="undiscovered" />
<div css={idleSpriteWrapperStyle(mood ?? 'awake')}>
<CharacterSprite
elementId={el.id}
elementColor={el.color}
tier={el.tier}
size={56}
state="undiscovered"
/>
</div>
</div>
<span css={undiscoveredNameStyle}>???</span>
{hint && (
<span css={undiscoveredHintStyle(hint.craftableNow)}>{hint.display}</span>
)}
</div>
);
}
@@ -1296,7 +1347,15 @@ const ElementCard = memo(function ElementCard({
<div css={lockedCardStyle}>
<span css={lockIconStyle}>🔒</span>
<div css={spriteWrapStyle}>
<CharacterSprite elementId={el.id} elementColor={el.color} tier={el.tier} size={56} state="locked" />
<div css={idleSpriteWrapperStyle(mood ?? 'awake')}>
<CharacterSprite
elementId={el.id}
elementColor={el.color}
tier={el.tier}
size={56}
state="locked"
/>
</div>
</div>
<span css={elementNameStyle}>{el.name}</span>
</div>
@@ -1310,7 +1369,9 @@ const ElementCard = memo(function ElementCard({
data-tutorial={isTutorialTarget ? 'elements-first-card' : undefined}
>
<div css={spriteWrapStyle}>
<CharacterSprite elementId={el.id} elementColor={el.color} tier={el.tier} size={56} state="obtained" />
<div css={idleSpriteWrapperStyle(mood ?? 'awake')}>
<CharacterSprite elementId={el.id} elementColor={el.color} tier={el.tier} size={56} state="obtained" />
</div>
</div>
<span css={elementNameStyle}>{el.name}</span>
<span css={elementCountStyle}>×{count.toLocaleString()}</span>
@@ -1478,10 +1539,31 @@ export function ElementsScreen() {
.filter((summary): summary is ActiveBoostSummary => summary !== null && summary.remainingMs > 0)
.sort((a, b) => a.remainingMs - b.remainingMs);
const tierGroups = [1, 2, 3, 4, 5].map((tier) => ({
tier,
items: discoveredElementList.filter((el) => el.tier === tier),
})).filter(({ items }) => items.length > 0);
const MAX_UNDISCOVERED_PER_TIER = 6;
const tierGroups = [1, 2, 3, 4, 5].map((tier) => {
const tierElements = elementsData.filter((el) => el.tier === tier);
const owned = tierElements.filter((el) => discoveredElementIds.includes(el.id));
const undiscovered = tierElements.filter((el) => !discoveredElementIds.includes(el.id));
const undiscoveredWithHints = undiscovered
.map((el) => ({ el, hint: getRecipeHintForElement(el.id, discoveredElementIds, elements) }))
.sort((a, b) => {
const aCraftable = a.hint?.craftableNow ? 1 : 0;
const bCraftable = b.hint?.craftableNow ? 1 : 0;
if (aCraftable !== bCraftable) return bCraftable - aCraftable;
const aHasHint = a.hint ? 1 : 0;
const bHasHint = b.hint ? 1 : 0;
return bHasHint - aHasHint;
})
.slice(0, MAX_UNDISCOVERED_PER_TIER);
return {
tier,
owned,
undiscovered: undiscoveredWithHints,
};
}).filter(({ owned, undiscovered }) => owned.length > 0 || undiscovered.length > 0);
// 첫 번째 obtained 원소 ID (튜토리얼 스포트라이트용)
const firstObtainedId = discoveredElementList[0]?.id ?? null;
@@ -1799,12 +1881,12 @@ export function ElementsScreen() {
</div>
</div>
{/* 티어별 보유 원소 */}
{tierGroups.map(({ tier, items }) => (
{/* 티어별 보유 원소 + 미발견 슬롯 힌트 */}
{tierGroups.map(({ tier, owned, undiscovered }) => (
<div key={tier} css={tierSectionStyle}>
<div css={tierLabelStyle}>{TIER_LABELS[tier]}</div>
<div css={elementGridStyle}>
{items.map((el) => (
{owned.map((el) => (
<ElementCard
key={el.id}
el={el}
@@ -1814,6 +1896,19 @@ export function ElementsScreen() {
onSelect={setSelectedEl}
isAutomated={el.tier <= tierAutomation.unlockedTier}
isTutorialTarget={el.id === firstObtainedId}
mood={idleMood}
/>
))}
{undiscovered.map(({ el, hint }) => (
<ElementCard
key={el.id}
el={el}
state="undiscovered"
count={0}
level={0}
onSelect={setSelectedEl}
hint={hint}
mood={idleMood}
/>
))}
</div>