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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user