Skip to content

Commit 5e95ea9

Browse files
feat: add relic run depth and vercel web config
1 parent 0562e61 commit 5e95ea9

11 files changed

Lines changed: 519 additions & 42 deletions

File tree

app/game.tsx

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Vec2 } from '../engine/math';
88
import {
99
initGameState, updateGame, extractHudData,
1010
activateAbility, pauseGame, resumeGame,
11-
applyLevelUpChoice, startNextWave,
11+
applyLevelUpChoice, applyRelicChoice, startNextWave,
1212
} from '../engine/GameEngine';
1313
import { saveHighScore } from '../services/storage';
1414
import { CHARACTERS, WEAPONS, EVOLVED_WEAPONS, ITEM_DEFS } from '../engine/data';
@@ -26,6 +26,7 @@ import HUD from '../components/game/HUD';
2626
import AbilityButton from '../components/game/AbilityButton';
2727
import PauseOverlay from '../components/game/PauseOverlay';
2828
import LevelUpModal from '../components/game/LevelUpModal';
29+
import RelicModal from '../components/game/RelicModal';
2930
import ShopOverlay from '../components/game/ShopOverlay';
3031
import GameOverOverlay from '../components/game/GameOverOverlay';
3132
import Minimap from '../components/game/Minimap';
@@ -37,7 +38,7 @@ const defaultHud: HudData = {
3738
hp: 100, maxHp: 100, armor: 0, waveNum: 1, waveTimer: 25, waveMaxTime: 25,
3839
materials: 0, level: 1, xp: 0, xpToNext: 10,
3940
abilityCd: 0, abilityMaxCd: 30, abilityEmoji: '\u2B50', phase: 'waveAnnounce',
40-
comboCount: 0, comboTimer: 0, bestCombo: 0,
41+
comboCount: 0, comboTimer: 0, comboMaxTime: 3.2, bestCombo: 0,
4142
petCount: 0, resourceCount: 0,
4243
equippedWeapons: [],
4344
};
@@ -385,6 +386,15 @@ export default function GameScreen() {
385386
}
386387
}, [currentRunKey]);
387388

389+
const handleRelicChoice = useCallback((idx: number) => {
390+
const state = gameRef.current;
391+
if (state && applyRelicChoice(state, idx)) {
392+
phaseRef.current = state.phase;
393+
setPhaseState({ runKey: currentRunKey, phase: state.phase });
394+
setHudData(extractHudData(state));
395+
}
396+
}, [currentRunKey]);
397+
388398
const handleNextWave = useCallback(() => {
389399
const state = gameRef.current;
390400
if (state) {
@@ -523,6 +533,12 @@ export default function GameScreen() {
523533
onChoose={handleLevelUp}
524534
/>
525535
)}
536+
{activePhase === 'relic' && (
537+
<RelicModal
538+
options={state?.relicChoices ?? []}
539+
onChoose={handleRelicChoice}
540+
/>
541+
)}
526542
{activePhase === 'shopping' && (
527543
<ShopOverlay gameState={gameRef} onNextWave={handleNextWave} />
528544
)}
@@ -563,6 +579,10 @@ export default function GameScreen() {
563579
items={(state?.player?.items ?? []).map(it => ({
564580
emoji: ITEM_DEFS.find(d => d.id === it?.id)?.emoji ?? '?',
565581
}))}
582+
relics={(state?.relics ?? []).map(relic => ({
583+
emoji: relic.emoji,
584+
name: relic.name,
585+
}))}
566586
achievements={runAchievements}
567587
onRetry={handleRetry}
568588
onMenu={handleMenu}

components/game/GameCanvas.tsx

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ export default function GameCanvas({ gameState, frame }: Props) {
4444
const visibleEffects = (effects ?? []).filter(fx => vis(fx.x, fx.y));
4545
const visibleDeathParticles = (deathParticles ?? []).filter(dp => vis(dp.x, dp.y));
4646
const visibleDmgNums = (dmgNums ?? []).filter(d => vis(d.x, d.y));
47+
const relicIds = new Set((state.relics ?? []).map(relic => relic.id));
48+
const effectivePickupRange = p.pickupRange * (relicIds.has('abyssalMagnet') ? 1.9 : 1) * (1 + p.luck * 0.01);
4749
const crowded = state.wave.number >= 5 || visibleEnemies.length + visibleProjectiles.length > 45 || visibleEffects.length + visibleDmgNums.length > 35;
4850
const displayProjectiles = crowded ? visibleProjectiles.slice(0, 48) : visibleProjectiles;
4951
const displayEffects = crowded ? visibleEffects.filter(fx => fx.kind !== 'muzzle' && fx.kind !== 'spark').slice(-14) : visibleEffects;
@@ -105,7 +107,7 @@ export default function GameCanvas({ gameState, frame }: Props) {
105107
const dx = pk.x - p.x;
106108
const dy = pk.y - p.y;
107109
const dist = Math.sqrt(dx * dx + dy * dy);
108-
const inMagnet = dist < p.pickupRange && dist > 12;
110+
const inMagnet = dist < effectivePickupRange && dist > 12;
109111
const trailColor = pk.type === 'egg' ? 'rgba(251,191,36,0.5)' : pk.type === 'heal' ? 'rgba(34,197,94,0.5)' : 'rgba(45,212,191,0.4)';
110112
return (
111113
<React.Fragment key={pk.id}>
@@ -185,8 +187,9 @@ export default function GameCanvas({ gameState, frame }: Props) {
185187
})}
186188
{/* Enemies */}
187189
{visibleEnemies.map(e => {
188-
const showTelegraph = e.telegraphTimer > 0 && e.telegraphType === 'attack';
190+
const showTelegraph = e.telegraphTimer > 0 && (e.telegraphType === 'attack' || e.telegraphType === 'charge');
189191
const telegraphProg = showTelegraph ? 1 - (e.telegraphTimer / e.telegraphMax) : 0;
192+
const chargeTelegraph = e.telegraphType === 'charge';
190193
const isElite = e.elite !== 'none';
191194
const visualFontSize = e.fontSize + (e.isBoss ? 0 : 2);
192195
return (
@@ -220,10 +223,22 @@ export default function GameCanvas({ gameState, frame }: Props) {
220223
}]} />
221224
)}
222225
{showTelegraph && (
223-
<View style={s.telegraphBarBg}>
224-
<View style={[s.telegraphBar, { width: `${Math.min(100, Math.max(8, telegraphProg * 100))}%` }]} />
226+
<View style={[s.telegraphBarBg, chargeTelegraph && s.chargeTelegraphBarBg]}>
227+
<View style={[s.telegraphBar, chargeTelegraph && s.chargeTelegraphBar, { width: `${Math.min(100, Math.max(8, telegraphProg * 100))}%` }]} />
225228
</View>
226229
)}
230+
{chargeTelegraph && (
231+
<View style={[
232+
s.chargeWarn,
233+
{
234+
width: e.radius * 4.8,
235+
left: e.radius - e.radius * 2.4,
236+
top: e.radius + 8,
237+
opacity: 0.3 + telegraphProg * 0.45,
238+
transform: [{ rotate: `${Math.atan2(e.chargeVy, e.chargeVx)}rad` }],
239+
},
240+
]} />
241+
)}
227242
<Text style={[s.entity, s.enemyEmoji, e.flashTimer > 0 && s.enemyEmojiHit, { fontSize: visualFontSize, opacity: e.flashTimer > 0 ? 0.45 : 1 }]}>
228243
{e.isBoss ? '👑' : ''}{e.emoji}
229244
</Text>
@@ -469,6 +484,26 @@ export default function GameCanvas({ gameState, frame }: Props) {
469484
</React.Fragment>
470485
);
471486
}
487+
if (fx.kind === 'smoke') {
488+
return (
489+
<View
490+
key={fx.id}
491+
style={[
492+
s.smokeFx,
493+
{
494+
left: fx.x - fx.radius * (0.4 + prog * 0.3),
495+
top: fx.y - fx.radius * (0.4 + prog * 0.3),
496+
width: fx.radius * (0.8 + prog * 0.6),
497+
height: fx.radius * (0.45 + prog * 0.45),
498+
borderRadius: fx.radius,
499+
backgroundColor: fx.color,
500+
opacity: (1 - prog) * 0.18,
501+
transform: [{ rotate: `${fx.angle}rad` }, { scale: 0.9 + prog * 0.8 }],
502+
},
503+
]}
504+
/>
505+
);
506+
}
472507
if (fx.kind === 'burst') {
473508
return (
474509
<React.Fragment key={fx.id}>
@@ -767,6 +802,9 @@ const s = StyleSheet.create({
767802
eliteAura: { position: 'absolute', borderWidth: 2, backgroundColor: 'transparent' },
768803
telegraphBarBg: { position: 'absolute', top: -8, width: 24, height: 3, borderRadius: 2, backgroundColor: 'rgba(239,68,68,0.18)', overflow: 'hidden' },
769804
telegraphBar: { height: 3, borderRadius: 2, backgroundColor: '#EF4444' },
805+
chargeTelegraphBarBg: { backgroundColor: 'rgba(56,189,248,0.18)' },
806+
chargeTelegraphBar: { backgroundColor: '#38BDF8' },
807+
chargeWarn: { position: 'absolute', height: 4, borderRadius: 3, backgroundColor: '#38BDF8', shadowColor: '#38BDF8', shadowOpacity: 0.6, shadowRadius: 8, shadowOffset: { width: 0, height: 0 } },
770808
hpBarBg: { width: 30, height: 3, backgroundColor: 'rgba(255,255,255,0.15)', borderRadius: 2, marginTop: 2 },
771809
hpBar: { height: 3, backgroundColor: '#EF4444', borderRadius: 2 },
772810
playerWrap: { position: 'absolute', alignItems: 'center', width: 48 },
@@ -801,6 +839,7 @@ const s = StyleSheet.create({
801839
shieldBar: { height: 2, backgroundColor: '#2DD4BF', borderRadius: 1 },
802840
sparkFx: { position: 'absolute' },
803841
sparkRay: { position: 'absolute', height: 2, borderRadius: 1 },
842+
smokeFx: { position: 'absolute' },
804843
burstFx: { position: 'absolute', borderWidth: 3, backgroundColor: 'transparent' },
805844
burstInner: { position: 'absolute' },
806845
deathParticle: { position: 'absolute', textAlign: 'center', fontWeight: '800' },

components/game/GameOverOverlay.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,13 @@ interface Props {
1313
victory: boolean;
1414
weapons: { id?: string; emoji: string; evolved?: boolean }[];
1515
items?: { emoji: string }[];
16+
relics?: { emoji: string; name: string }[];
1617
achievements?: { name: string; emoji: string; description: string }[];
1718
onRetry: () => void;
1819
onMenu: () => void;
1920
}
2021

21-
export default function GameOverOverlay({ stats, waveNum, materials, playerEmoji, victory, weapons, items = [], achievements = [], onRetry, onMenu }: Props) {
22+
export default function GameOverOverlay({ stats, waveNum, materials, playerEmoji, victory, weapons, items = [], relics = [], achievements = [], onRetry, onMenu }: Props) {
2223
const endedAtRef = React.useRef(Date.now());
2324
const elapsed = stats ? Math.round((endedAtRef.current - (stats.startTime ?? endedAtRef.current)) / 1000) : 0;
2425
const score = calculateRunScore({ wave: waveNum, kills: stats?.enemiesKilled ?? 0, time: elapsed });
@@ -69,6 +70,16 @@ export default function GameOverOverlay({ stats, waveNum, materials, playerEmoji
6970
</View>
7071
</View>
7172
)}
73+
{relics.length > 0 && (
74+
<View style={s.section}>
75+
<Text style={s.sectionLabel}>Relics</Text>
76+
<View style={s.relicRow}>
77+
{relics.map((relic, i) => (
78+
<Text key={i} style={s.relicPill}>{relic.emoji} {relic.name}</Text>
79+
))}
80+
</View>
81+
</View>
82+
)}
7283
{modifiers.length > 0 && (
7384
<View style={s.section}>
7485
<Text style={s.sectionLabel}>Modifiers Faced</Text>
@@ -117,6 +128,8 @@ const s = StyleSheet.create({
117128
itemEmoji: { fontSize: 20 },
118129
modRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
119130
modPill: { color: '#F59E0B', fontSize: 11, fontWeight: '700', backgroundColor: 'rgba(245,158,11,0.15)', borderRadius: 8, paddingHorizontal: 8, paddingVertical: 3, borderWidth: 1, borderColor: 'rgba(245,158,11,0.3)' },
131+
relicRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 6 },
132+
relicPill: { color: '#EDE9FE', fontSize: 11, fontWeight: '800', backgroundColor: 'rgba(168,85,247,0.14)', borderRadius: 8, paddingHorizontal: 8, paddingVertical: 4, borderWidth: 1, borderColor: 'rgba(168,85,247,0.3)' },
120133
achievementsBox: { backgroundColor: 'rgba(245,158,11,0.1)', borderRadius: 10, padding: 10, marginBottom: 12, width: '100%', borderWidth: 1, borderColor: 'rgba(245,158,11,0.25)' },
121134
achievementsTitle: { color: '#F59E0B', fontSize: 12, fontWeight: '800', marginBottom: 6 },
122135
achievementRow: { color: '#FFF', fontSize: 13, marginBottom: 3 },

components/game/HUD.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,17 @@ export default function HUD({ data, onPause }: Props) {
9999
<View style={s.comboPill}>
100100
<Text style={s.comboText}>{data.comboCount}x</Text>
101101
<View style={s.comboTimerBg}>
102-
<View style={[s.comboTimerFill, { width: `${Math.max(0, Math.min(100, (data.comboTimer / 3.2) * 100))}%` }]} />
102+
<View style={[s.comboTimerFill, { width: `${Math.max(0, Math.min(100, (data.comboTimer / Math.max(0.1, data.comboMaxTime ?? 3.2)) * 100))}%` }]} />
103103
</View>
104104
</View>
105105
)}
106+
{(data?.relics?.length ?? 0) > 0 && (
107+
<View style={s.relicPill}>
108+
{data.relics!.slice(0, 4).map(relic => (
109+
<Text key={relic.id} style={s.relicText}>{relic.emoji}</Text>
110+
))}
111+
</View>
112+
)}
106113
{showModifier && data?.waveModifier && data.waveModifier !== 'none' && (
107114
<View style={s.modifierBanner}>
108115
<Text style={s.modifierText}>{MODIFIER_NAMES[data.waveModifier] ?? data.waveModifier}</Text>
@@ -165,6 +172,8 @@ const s = StyleSheet.create({
165172
comboText: { color: '#CCFBF1', fontSize: 15, fontWeight: '900', textShadowColor: 'rgba(0,0,0,0.6)', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 2 },
166173
comboTimerBg: { width: 54, height: 3, borderRadius: 2, backgroundColor: 'rgba(255,255,255,0.16)', marginTop: 3, overflow: 'hidden' },
167174
comboTimerFill: { height: 3, borderRadius: 2, backgroundColor: '#2DD4BF' },
175+
relicPill: { flexDirection: 'row', gap: 5, paddingHorizontal: 9, paddingVertical: 5, borderRadius: 10, backgroundColor: 'rgba(168,85,247,0.14)', borderWidth: 1, borderColor: 'rgba(168,85,247,0.3)' },
176+
relicText: { fontSize: 13 },
168177
objectivePill: { paddingHorizontal: 9, paddingVertical: 5, borderRadius: 10, backgroundColor: 'rgba(15,23,42,0.72)', borderWidth: 1, borderColor: 'rgba(125,211,252,0.22)' },
169178
objectiveText: { color: '#BAE6FD', fontSize: 11, fontWeight: '800' },
170179
modifierBanner: { paddingHorizontal: 16, paddingVertical: 6, borderRadius: 12, backgroundColor: 'rgba(245,158,11,0.2)', borderWidth: 1, borderColor: 'rgba(245,158,11,0.5)' },

components/game/Minimap.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ interface Props {
1010

1111
export default function Minimap({ gameState, size = 120 }: Props) {
1212
const state = gameState.current;
13-
if (!state || state.phase === 'gameover' || state.phase === 'paused' || state.phase === 'levelup' || state.phase === 'shopping') {
13+
if (!state || state.phase === 'gameover' || state.phase === 'paused' || state.phase === 'levelup' || state.phase === 'relic' || state.phase === 'shopping') {
1414
return null; // Don't render during menus
1515
}
1616

components/game/RelicModal.tsx

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import { Animated, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
3+
import type { RelicChoice } from '../../engine/types';
4+
import { RARITY_COLORS } from '../../engine/constants';
5+
6+
interface Props {
7+
options: RelicChoice[];
8+
onChoose: (index: number) => void;
9+
}
10+
11+
export default function RelicModal({ options, onChoose }: Props) {
12+
const opacityAnim = useRef(new Animated.Value(0)).current;
13+
const slideAnim = useRef(new Animated.Value(18)).current;
14+
15+
useEffect(() => {
16+
Animated.parallel([
17+
Animated.timing(opacityAnim, { toValue: 1, duration: 180, useNativeDriver: true }),
18+
Animated.spring(slideAnim, { toValue: 0, friction: 7, tension: 120, useNativeDriver: true }),
19+
]).start();
20+
}, [opacityAnim, slideAnim]);
21+
22+
return (
23+
<Animated.View style={[s.overlay, { opacity: opacityAnim }]}>
24+
<Animated.View style={[s.panel, { transform: [{ translateY: slideAnim }] }]}>
25+
<Text style={s.kicker}>BOSS RELIC</Text>
26+
<Text style={s.title}>Choose a Tide Relic</Text>
27+
<ScrollView contentContainerStyle={s.optionRow} showsVerticalScrollIndicator={false}>
28+
{(options ?? []).map((opt, i) => {
29+
const color = RARITY_COLORS[opt.rarity] ?? '#9CA3AF';
30+
return (
31+
<Pressable
32+
key={opt.id}
33+
onPress={() => onChoose(i)}
34+
style={({ pressed }) => [
35+
s.option,
36+
{ borderColor: color },
37+
pressed && s.optionPressed,
38+
]}
39+
accessibilityRole="button"
40+
accessibilityLabel={`${opt.name}. ${opt.desc} ${opt.drawback}`}
41+
>
42+
<Text style={s.emoji}>{opt.emoji}</Text>
43+
<Text style={s.name}>{opt.name}</Text>
44+
<Text style={s.desc}>{opt.desc}</Text>
45+
<View style={s.tradeoff}>
46+
<Text style={s.tradeoffText}>{opt.drawback}</Text>
47+
</View>
48+
<Text style={[s.rarity, { color }]}>{opt.rarity.toUpperCase()}</Text>
49+
</Pressable>
50+
);
51+
})}
52+
</ScrollView>
53+
</Animated.View>
54+
</Animated.View>
55+
);
56+
}
57+
58+
const s = StyleSheet.create({
59+
overlay: { ...StyleSheet.absoluteFillObject, backgroundColor: 'rgba(2,6,23,0.82)', justifyContent: 'center', alignItems: 'center', zIndex: 50, paddingHorizontal: 16 },
60+
panel: { width: '100%', maxWidth: 740, maxHeight: '88%', borderRadius: 18, borderWidth: 1, borderColor: 'rgba(168,85,247,0.35)', backgroundColor: 'rgba(15,25,60,0.97)', padding: 20, shadowColor: '#A855F7', shadowOpacity: 0.28, shadowRadius: 18, shadowOffset: { width: 0, height: 0 } },
61+
kicker: { color: '#A78BFA', fontSize: 11, fontWeight: '900', letterSpacing: 2, textAlign: 'center' },
62+
title: { color: '#FFF', fontSize: 25, fontWeight: '900', textAlign: 'center', marginTop: 4, marginBottom: 16 },
63+
optionRow: { flexDirection: 'row', gap: 10, flexWrap: 'wrap', justifyContent: 'center' },
64+
option: { width: 220, minHeight: 232, borderRadius: 12, borderWidth: 2, backgroundColor: 'rgba(255,255,255,0.06)', padding: 14, alignItems: 'center' },
65+
optionPressed: { transform: [{ scale: 0.98 }], backgroundColor: 'rgba(255,255,255,0.1)' },
66+
emoji: { fontSize: 38, marginBottom: 8 },
67+
name: { color: '#FFF', fontSize: 16, fontWeight: '900', textAlign: 'center' },
68+
desc: { color: '#DDEAFE', fontSize: 12, fontWeight: '700', lineHeight: 17, textAlign: 'center', marginTop: 8 },
69+
tradeoff: { marginTop: 'auto', paddingHorizontal: 9, paddingVertical: 7, borderRadius: 9, backgroundColor: 'rgba(239,68,68,0.1)', borderWidth: 1, borderColor: 'rgba(248,113,113,0.18)' },
70+
tradeoffText: { color: '#FCA5A5', fontSize: 11, fontWeight: '800', lineHeight: 15, textAlign: 'center' },
71+
rarity: { marginTop: 8, fontSize: 10, fontWeight: '900', letterSpacing: 1 },
72+
});

0 commit comments

Comments
 (0)