Skip to content

Commit 2a474bd

Browse files
refactor arena visuals with SVG sand, water, and submerge tint
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2d41872 commit 2a474bd

3 files changed

Lines changed: 418 additions & 294 deletions

File tree

assets/arena/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Arena ground assets (optional Path B)
2+
3+
The default arena uses **code-driven** visuals in `components/game/arena/ArenaBackground.tsx` (SVG + gradients). No bitmap is required.
4+
5+
To add a **baked ground plate** later:
6+
7+
1. Export a **2048×2048** (or **1024×1024**) top-down underwater/sand plate as **WebP** or PNG. Prefer **WebP** under ~800KB for mobile.
8+
2. Place the file here, e.g. `ground-albedo.webp`.
9+
3. In `ArenaBackground.tsx`, wrap or underlay an `Image` / `ImageBackground` from `expo-image` or `react-native` with `width`/`height` matching the arena (`ARENA_W` / `ARENA_H` from `engine/constants.ts`), `resizeMode="cover"`.
10+
4. Keep **gameplay water detection** unchanged: it uses `WATER_ZONES` in `engine/constants.ts` only. Align painted water in the texture with those circles, or leave the texture neutral and keep the SVG water layers.
11+
12+
**Licensing:** If the texture is AI-generated, from a stock site, or third-party, add a `LICENSE` or `CREDITS.txt` next to the file stating terms. CC0/public-domain textures are safest for distribution.

components/game/GameCanvas.tsx

Lines changed: 16 additions & 294 deletions
Original file line numberDiff line numberDiff line change
@@ -2,87 +2,7 @@ import React from 'react';
22
import { View, Text, StyleSheet } from 'react-native';
33
import type { GameState } from '../../engine/types';
44
import { ELITE_EMOJIS, ELITE_COLORS } from '../../engine/data';
5-
import { WATER_ZONES } from '../../engine/constants';
6-
7-
const GRID_STEP = 160;
8-
const GRID_LINES = Array.from({ length: Math.floor(2000 / GRID_STEP) + 1 }, (_, i) => i * GRID_STEP);
9-
const STARS = Array.from({ length: 46 }, (_, i) => ({
10-
id: i,
11-
x: (i * 173) % 1960 + 20,
12-
y: (i * 311) % 1960 + 20,
13-
size: 1 + (i % 3),
14-
opacity: 0.12 + (i % 5) * 0.045,
15-
}));
16-
const CURRENTS = Array.from({ length: 12 }, (_, i) => ({
17-
id: i,
18-
x: (i * 227) % 1850 + 60,
19-
y: (i * 149) % 1840 + 80,
20-
width: 90 + (i % 4) * 28,
21-
angle: -18 + (i % 5) * 9,
22-
opacity: 0.05 + (i % 3) * 0.025,
23-
}));
24-
const BUBBLES = Array.from({ length: 24 }, (_, i) => ({
25-
id: i,
26-
x: (i * 131) % 1900 + 35,
27-
y: (i * 211) % 1900 + 35,
28-
size: 3 + (i % 4),
29-
speed: 0.28 + (i % 5) * 0.08,
30-
}));
31-
const REEF_PROPS = [
32-
{ id: 1, x: 170, y: 260, emoji: '🪸', size: 30, layer: 0 },
33-
{ id: 2, x: 420, y: 1560, emoji: '🌿', size: 24, layer: 1 },
34-
{ id: 3, x: 820, y: 300, emoji: '🪨', size: 26, layer: 0 },
35-
{ id: 4, x: 1240, y: 1680, emoji: '🪸', size: 34, layer: 1 },
36-
{ id: 5, x: 1620, y: 520, emoji: '🌿', size: 26, layer: 0 },
37-
{ id: 6, x: 1760, y: 1320, emoji: '💠', size: 22, layer: 1 },
38-
{ id: 7, x: 620, y: 1020, emoji: '🪸', size: 24, layer: 0 },
39-
{ id: 8, x: 1420, y: 940, emoji: '🪨', size: 28, layer: 1 },
40-
{ id: 9, x: 920, y: 820, emoji: '🌿', size: 23, layer: 0 },
41-
{ id: 10, x: 1080, y: 1180, emoji: '🪸', size: 26, layer: 1 },
42-
{ id: 11, x: 780, y: 1220, emoji: '💠', size: 20, layer: 0 },
43-
{ id: 12, x: 1200, y: 760, emoji: '🪨', size: 24, layer: 1 },
44-
{ id: 13, x: 300, y: 800, emoji: '🐚', size: 20, layer: 0 },
45-
{ id: 14, x: 1550, y: 200, emoji: '🪸', size: 28, layer: 1 },
46-
{ id: 15, x: 500, y: 1350, emoji: '🌊', size: 22, layer: 0 },
47-
{ id: 16, x: 1700, y: 900, emoji: '🪸', size: 32, layer: 1 },
48-
{ id: 17, x: 100, y: 1700, emoji: '🐚', size: 24, layer: 0 },
49-
{ id: 18, x: 950, y: 1600, emoji: '🌿', size: 28, layer: 1 },
50-
];
51-
const CAUSTIC_LIGHTS = Array.from({ length: 8 }, (_, i) => ({
52-
id: i,
53-
x: (i * 293) % 1700 + 150,
54-
y: (i * 197) % 1700 + 150,
55-
width: 140 + (i % 3) * 60,
56-
height: 80 + (i % 4) * 30,
57-
angle: (i * 37) % 360,
58-
speed: 0.006 + (i % 3) * 0.003,
59-
drift: 0.008 + (i % 4) * 0.004,
60-
}));
61-
const KELP_CLUSTERS = Array.from({ length: 10 }, (_, i) => ({
62-
id: i,
63-
x: (i * 199) % 1800 + 100,
64-
y: (i * 263) % 1800 + 100,
65-
height: 40 + (i % 3) * 16,
66-
blades: 3 + (i % 3),
67-
hue: i % 2 === 0 ? 'rgba(34,197,94,0.14)' : 'rgba(20,184,166,0.12)',
68-
}));
69-
const DEPTH_MOTES = Array.from({ length: 18 }, (_, i) => ({
70-
id: i,
71-
x: (i * 157) % 1920 + 30,
72-
y: (i * 239) % 1920 + 30,
73-
size: 2 + (i % 3) * 1.5,
74-
driftX: 0.012 + (i % 5) * 0.006,
75-
driftY: 0.008 + (i % 4) * 0.005,
76-
opacity: 0.06 + (i % 4) * 0.03,
77-
color: i % 3 === 0 ? '#A78BFA' : i % 3 === 1 ? '#2DD4BF' : '#BAE6FD',
78-
}));
79-
const SAND_BARS = [
80-
{ id: 1, x: 170, y: 180, width: 520, height: 200, rot: -9 },
81-
{ id: 2, x: 1250, y: 150, width: 560, height: 220, rot: 11 },
82-
{ id: 3, x: 210, y: 1280, width: 680, height: 260, rot: 7 },
83-
{ id: 4, x: 1080, y: 1220, width: 700, height: 280, rot: -6 },
84-
{ id: 5, x: 730, y: 760, width: 460, height: 180, rot: 4 },
85-
];
5+
import ArenaBackground from './arena/ArenaBackground';
866

877
interface Props {
888
gameState: React.RefObject<GameState | null>;
@@ -114,206 +34,19 @@ export default function GameCanvas({ gameState, frame }: Props) {
11434
const vMinY = camera.y - sh / 2 - 60;
11535
const vMaxY = camera.y + sh / 2 + 60;
11636
const vis = (x: number, y: number) => x >= vMinX && x <= vMaxX && y >= vMinY && y <= vMaxY;
117-
const visRect = (x: number, y: number, width: number, height: number) => (
118-
x + width >= vMinX && x <= vMaxX && y + height >= vMinY && y <= vMaxY
119-
);
120-
const visibleGridX = GRID_LINES.filter(x => x >= vMinX && x <= vMaxX);
121-
const visibleGridY = GRID_LINES.filter(y => y >= vMinY && y <= vMaxY);
122-
const visibleCurrents = CURRENTS.filter(current => visRect(current.x - 30, current.y - 30, current.width + 60, 60));
123-
const visibleReefProps = REEF_PROPS.filter(prop => visRect(prop.x - 20, prop.y - 20, prop.size + 40, prop.size + 40));
124-
const visibleStars = STARS.filter(star => vis(star.x, star.y));
125-
const visibleBubbles = BUBBLES.filter(bubble => {
126-
const y = ((bubble.y - frame * bubble.speed) % 1960 + 1960) % 1960 + 20;
127-
return vis(bubble.x, y);
128-
});
12937
const showCritFlash = state.hitStop > 0.06;
13038

13139
return (
13240
<View style={s.viewport} pointerEvents="none">
13341
<View style={[s.world, { transform: [{ translateX: tx }, { translateY: ty }] }]}>
134-
{/* Arena bg */}
135-
<View style={[s.arenaBg, { width: arena.width, height: arena.height }]}>
136-
<View style={s.arenaGlowA} />
137-
<View style={s.arenaGlowB} />
138-
{SAND_BARS.map(bar => (
139-
<View
140-
key={`sand-${bar.id}`}
141-
style={[
142-
s.sandPatch,
143-
{
144-
left: bar.x,
145-
top: bar.y,
146-
width: bar.width,
147-
height: bar.height,
148-
borderRadius: Math.round(bar.height * 0.52),
149-
transform: [{ rotate: `${bar.rot}deg` }],
150-
},
151-
]}
152-
/>
153-
))}
154-
{WATER_ZONES.map((zone, i) => (
155-
<View
156-
key={`water-${i}`}
157-
style={[
158-
s.waterZone,
159-
{
160-
left: zone.x - zone.radius,
161-
top: zone.y - zone.radius,
162-
width: zone.radius * 2,
163-
height: zone.radius * 2,
164-
borderRadius: zone.radius,
165-
opacity: 0.2 + Math.sin(frame * 0.035 + i) * 0.04,
166-
},
167-
]}
168-
>
169-
<View style={s.waterZoneInner} />
170-
</View>
171-
))}
172-
<View style={[s.arenaGlowC, {
173-
left: 600 + Math.sin(frame * 0.005) * 120,
174-
top: 800 + Math.cos(frame * 0.004) * 90,
175-
}]} />
176-
{/* Caustic light rays */}
177-
{CAUSTIC_LIGHTS.map(cl => (
178-
<View
179-
key={`cl-${cl.id}`}
180-
style={[
181-
s.causticLight,
182-
{
183-
left: cl.x + Math.sin(frame * cl.drift + cl.id) * 40,
184-
top: cl.y + Math.cos(frame * cl.drift * 0.7 + cl.id * 3) * 30,
185-
width: cl.width,
186-
height: cl.height,
187-
borderRadius: cl.height / 2,
188-
opacity: 0.035 + Math.sin(frame * cl.speed + cl.id * 2) * 0.02,
189-
transform: [{ rotate: `${cl.angle + Math.sin(frame * 0.003 + cl.id) * 12}deg` }],
190-
},
191-
]}
192-
/>
193-
))}
194-
{/* Kelp clusters */}
195-
{KELP_CLUSTERS.map(kc => (
196-
<View key={`kc-${kc.id}`} style={[s.kelpCluster, { left: kc.x, top: kc.y }]}>
197-
{Array.from({ length: kc.blades }, (_, bi) => {
198-
const sway = Math.sin((frame + kc.id * 17 + bi * 11) * 0.025) * 8;
199-
return (
200-
<View
201-
key={bi}
202-
style={[
203-
s.kelpBlade,
204-
{
205-
height: kc.height + bi * 6,
206-
backgroundColor: kc.hue,
207-
left: bi * 5 - (kc.blades * 2.5),
208-
transform: [{ rotate: `${sway + (bi - 1) * 3}deg` }, { translateY: -kc.height / 2 }],
209-
},
210-
]}
211-
/>
212-
);
213-
})}
214-
</View>
215-
))}
216-
{visibleCurrents.map(current => (
217-
<View
218-
key={`cur-${current.id}`}
219-
style={[
220-
s.currentLine,
221-
{
222-
left: current.x,
223-
top: current.y + Math.sin((frame + current.id * 17) * 0.018) * 14,
224-
width: current.width,
225-
opacity: current.opacity,
226-
transform: [
227-
{ rotate: `${current.angle}deg` },
228-
{ translateX: Math.sin((frame + current.id * 11) * 0.022) * 18 },
229-
],
230-
},
231-
]}
232-
/>
233-
))}
234-
{visibleReefProps.map(prop => (
235-
<Text
236-
key={`reef-${prop.id}`}
237-
style={[
238-
s.reefProp,
239-
{
240-
left: prop.x,
241-
top: prop.y + Math.sin((frame + prop.id * 19) * 0.025) * (2 + prop.layer),
242-
fontSize: prop.size,
243-
opacity: 0.18 + Math.sin((frame + prop.id * 13) * 0.02) * 0.05 + prop.layer * 0.06,
244-
},
245-
]}
246-
>
247-
{prop.emoji}
248-
</Text>
249-
))}
250-
{BUBBLES.map(bubble => {
251-
const bx = bubble.x + Math.sin((frame + bubble.id * 7) * 0.035) * 7;
252-
const by = ((bubble.y - frame * bubble.speed) % 1960 + 1960) % 1960 + 20;
253-
if (!vis(bx, by)) return null;
254-
return (
255-
<View
256-
key={`bubble-${bubble.id}`}
257-
style={[
258-
s.bubble,
259-
{
260-
left: bx,
261-
top: by,
262-
width: bubble.size,
263-
height: bubble.size,
264-
borderRadius: bubble.size,
265-
opacity: 0.08 + (bubble.id % 4) * 0.025,
266-
},
267-
]}
268-
/>
269-
);
270-
})}
271-
{/* Depth motes */}
272-
{DEPTH_MOTES.map(mote => {
273-
const mx = mote.x + Math.sin(frame * mote.driftX + mote.id * 5) * 30;
274-
const my = mote.y + Math.cos(frame * mote.driftY + mote.id * 3) * 25;
275-
return (
276-
<View
277-
key={`mote-${mote.id}`}
278-
style={[
279-
s.depthMote,
280-
{
281-
left: mx,
282-
top: my,
283-
width: mote.size,
284-
height: mote.size,
285-
borderRadius: mote.size,
286-
backgroundColor: mote.color,
287-
opacity: mote.opacity + Math.sin(frame * 0.03 + mote.id) * 0.02,
288-
},
289-
]}
290-
/>
291-
);
292-
})}
293-
{visibleGridX.map(x => <View key={`vx-${x}`} style={[s.gridLineV, { left: x, height: arena.height }]} />)}
294-
{visibleGridY.map(y => <View key={`hy-${y}`} style={[s.gridLineH, { top: y, width: arena.width }]} />)}
295-
{visibleStars.map(star => (
296-
<View
297-
key={star.id}
298-
style={[
299-
s.star,
300-
{
301-
left: star.x,
302-
top: star.y,
303-
width: star.size,
304-
height: star.size,
305-
borderRadius: star.size,
306-
opacity: Math.min(0.55, star.opacity + ((frame + star.id) % 90 < 45 ? 0.08 : 0)),
307-
},
308-
]}
309-
/>
310-
))}
311-
{/* Arena border pulse */}
312-
<View style={[s.arenaBorderPulse, {
313-
width: arena.width, height: arena.height,
314-
borderColor: `rgba(45,212,191,${0.12 + Math.sin(frame * 0.02) * 0.06})`,
315-
}]} />
316-
</View>
42+
<ArenaBackground
43+
width={arena.width}
44+
height={arena.height}
45+
frame={frame}
46+
camera={camera}
47+
sw={sw}
48+
sh={sh}
49+
/>
31750
{/* Hazards */}
31851
{(hazards ?? []).filter(h => vis(h.x, h.y)).map(h => {
31952
const pulse = 0.5 + Math.sin(h.pulse) * 0.18;
@@ -794,6 +527,11 @@ export default function GameCanvas({ gameState, frame }: Props) {
794527
);
795528
})}
796529
</View>
530+
{state.inWater && (
531+
<View style={s.submergeOverlay} pointerEvents="none">
532+
<View style={s.submergeVignette} />
533+
</View>
534+
)}
797535
<OffScreenMarkers state={state} />
798536
{(p.hp / p.maxHp) < 0.32 && <DangerVignette frame={frame} />}
799537
{showCritFlash && <CritFlash frame={frame} />}
@@ -867,24 +605,8 @@ function OffScreenMarkers({ state }: { state: GameState }) {
867605
const s = StyleSheet.create({
868606
viewport: { ...StyleSheet.absoluteFillObject, overflow: 'hidden', backgroundColor: '#030712' },
869607
world: { position: 'absolute', left: 0, top: 0 },
870-
arenaBg: { position: 'absolute', left: 0, top: 0, backgroundColor: '#050A15', borderWidth: 2, borderColor: 'rgba(45,212,191,0.24)', overflow: 'hidden' },
871-
arenaGlowA: { position: 'absolute', width: 620, height: 620, borderRadius: 310, left: 120, top: 120, backgroundColor: 'rgba(124,58,237,0.1)' },
872-
arenaGlowB: { position: 'absolute', width: 760, height: 760, borderRadius: 380, right: 120, bottom: 120, backgroundColor: 'rgba(20,184,166,0.08)' },
873-
sandPatch: { position: 'absolute', backgroundColor: 'rgba(250,204,21,0.09)', borderWidth: 1, borderColor: 'rgba(234,179,8,0.16)' },
874-
waterZone: { position: 'absolute', borderWidth: 2, borderColor: 'rgba(56,189,248,0.35)', backgroundColor: 'rgba(14,116,144,0.18)', alignItems: 'center', justifyContent: 'center' },
875-
waterZoneInner: { width: '72%', height: '72%', borderRadius: 999, borderWidth: 1, borderColor: 'rgba(125,211,252,0.22)', backgroundColor: 'rgba(30,64,175,0.12)' },
876-
arenaGlowC: { position: 'absolute', width: 500, height: 500, borderRadius: 250, backgroundColor: 'rgba(99,102,241,0.06)' },
877-
causticLight: { position: 'absolute', backgroundColor: 'rgba(186,230,253,0.06)' },
878-
kelpCluster: { position: 'absolute', width: 30, height: 60, alignItems: 'center' },
879-
kelpBlade: { position: 'absolute', width: 4, borderRadius: 2, bottom: 0 },
880-
currentLine: { position: 'absolute', height: 2, borderRadius: 2, backgroundColor: '#BAE6FD' },
881-
reefProp: { position: 'absolute', textShadowColor: 'rgba(0,0,0,0.5)', textShadowOffset: { width: 0, height: 2 }, textShadowRadius: 4 },
882-
bubble: { position: 'absolute', borderWidth: 1, borderColor: 'rgba(186,230,253,0.55)', backgroundColor: 'rgba(186,230,253,0.04)' },
883-
depthMote: { position: 'absolute' },
884-
gridLineV: { position: 'absolute', top: 0, width: 1, backgroundColor: 'rgba(148,163,184,0.055)' },
885-
gridLineH: { position: 'absolute', left: 0, height: 1, backgroundColor: 'rgba(148,163,184,0.055)' },
886-
star: { position: 'absolute', backgroundColor: '#E0F2FE' },
887-
arenaBorderPulse: { position: 'absolute', left: 0, top: 0, borderWidth: 3, backgroundColor: 'transparent' },
608+
submergeOverlay: { ...StyleSheet.absoluteFillObject, zIndex: 3, backgroundColor: 'rgba(8,47,73,0.14)' },
609+
submergeVignette: { ...StyleSheet.absoluteFillObject, borderWidth: 22, borderColor: 'rgba(14,116,144,0.22)', backgroundColor: 'transparent' },
888610
entity: { position: 'absolute', textAlign: 'center' },
889611
hazardWrap: { position: 'absolute', alignItems: 'center', justifyContent: 'center', borderWidth: 2, borderColor: 'rgba(251,146,60,0.38)', backgroundColor: 'rgba(251,146,60,0.07)' },
890612
hazardPulse: { position: 'absolute', borderWidth: 2, borderColor: 'rgba(251,146,60,0.42)', backgroundColor: 'rgba(251,146,60,0.08)' },

0 commit comments

Comments
 (0)