Replace plain list with a styled SVG Italy outline (mainland, Sicily, Sardinia). Cities rendered as markers: color by risk level, pulse animation for current city, ⚡ icon for active events. Clicking a marker or a list row opens a travel modal with city details and confirm button. List remains as compact fallback below the map. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
168 lines
5.9 KiB
TypeScript
168 lines
5.9 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import { api } from '../api/api';
|
||
import { ApiError } from '../api/http';
|
||
import type { City, WorldEvent } from '../api/types';
|
||
import { useAuth } from '../auth/AuthContext';
|
||
import { useToast } from '../components/Toast';
|
||
import { formatMoney, riskLabel } from '../shared/format';
|
||
|
||
/** Sagoma stilizzata dell'Italia (coordinate 0-100, stesse di City.mapX/mapY). */
|
||
const ITALY_MAINLAND =
|
||
'M12,18 L18,11 L30,9 L44,8 L50,13 L54,20 L60,28 L70,40 L82,46 L91,52 L88,57 L79,58 ' +
|
||
'L71,63 L65,71 L63,79 L58,76 L57,67 L52,58 L45,46 L37,34 L25,27 L15,24 Z';
|
||
const ITALY_SICILY = 'M44,83 L54,81 L62,84 L58,91 L48,93 L43,88 Z';
|
||
const ITALY_SARDINIA = 'M13,55 L21,56 L22,66 L19,74 L12,72 L11,62 Z';
|
||
|
||
const RISK_COLORS: Record<string, string> = {
|
||
basso: '#4caf6e',
|
||
medio: '#d4a017',
|
||
alto: '#d9534f',
|
||
};
|
||
|
||
export function TravelScreen() {
|
||
const { player, setPlayer } = useAuth();
|
||
const { toast } = useToast();
|
||
const [cities, setCities] = useState<City[]>([]);
|
||
const [events, setEvents] = useState<WorldEvent[]>([]);
|
||
const [selected, setSelected] = useState<City | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
const reload = useCallback(async () => {
|
||
const [c, e] = await Promise.all([api.cities(), api.events()]);
|
||
setCities(c.cities);
|
||
setEvents(e.events);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void reload();
|
||
}, [reload]);
|
||
|
||
const eventsByCity = useMemo(() => {
|
||
const map = new Map<string, WorldEvent[]>();
|
||
for (const event of events) {
|
||
const key = event.city?.id ?? 'global';
|
||
map.set(key, [...(map.get(key) ?? []), event]);
|
||
}
|
||
return map;
|
||
}, [events]);
|
||
|
||
const cityEvents = (cityId: string): WorldEvent[] => [
|
||
...(eventsByCity.get(cityId) ?? []),
|
||
...(eventsByCity.get('global') ?? []),
|
||
];
|
||
|
||
async function travelTo(city: City) {
|
||
setBusy(true);
|
||
try {
|
||
const res = await api.travel(city.id);
|
||
setPlayer(res.player);
|
||
toast(`Benvenuto a ${res.cityName} (−${formatMoney(res.cost)})`, 'success');
|
||
setSelected(null);
|
||
} catch (err) {
|
||
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="screen">
|
||
<h2 className="screen__title">Mappa</h2>
|
||
|
||
<div className="map-card">
|
||
<svg viewBox="0 0 100 100" className="map-svg" role="img" aria-label="Mappa delle città">
|
||
<path d={ITALY_MAINLAND} className="map-land" />
|
||
<path d={ITALY_SICILY} className="map-land" />
|
||
<path d={ITALY_SARDINIA} className="map-land" />
|
||
|
||
{cities.map((city) => {
|
||
const isCurrent = player?.currentCityId === city.id;
|
||
const hasEvent = (eventsByCity.get(city.id) ?? []).length > 0;
|
||
const color = RISK_COLORS[riskLabel(city.riskLevel)] ?? '#d4a017';
|
||
return (
|
||
<g
|
||
key={city.id}
|
||
className="map-marker"
|
||
onClick={() => setSelected(city)}
|
||
transform={`translate(${city.mapX}, ${city.mapY})`}
|
||
>
|
||
{isCurrent && <circle r="4.5" className="map-marker__pulse" fill={color} />}
|
||
<circle r="2.6" fill="#0f0f13" stroke={color} strokeWidth="0.8" />
|
||
<circle r="1" fill={isCurrent ? '#e8e4da' : color} />
|
||
{hasEvent && (
|
||
<text x="2.6" y="-2.2" fontSize="3.5">
|
||
⚡
|
||
</text>
|
||
)}
|
||
<text x="0" y="6.8" textAnchor="middle" className="map-marker__label">
|
||
{city.name}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
<p className="muted small map-legend">
|
||
● colore = rischio (verde basso, oro medio, rosso alto) · ⚡ evento in corso · tocca una
|
||
città per viaggiare
|
||
</p>
|
||
</div>
|
||
|
||
<ul className="list">
|
||
{cities.map((city) => {
|
||
const isCurrent = player?.currentCityId === city.id;
|
||
return (
|
||
<li
|
||
key={city.id}
|
||
className={`list__row list__row--clickable${isCurrent ? ' is-current' : ''}`}
|
||
onClick={() => setSelected(city)}
|
||
>
|
||
<div>
|
||
<strong>{city.name}</strong>
|
||
{isCurrent && <span className="badge badge--small">sei qui</span>}
|
||
{(eventsByCity.get(city.id) ?? []).length > 0 && <span> ⚡</span>}
|
||
</div>
|
||
<span className="muted small">
|
||
{isCurrent ? '—' : formatMoney(city.travelCost)}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
{selected && (
|
||
<div className="modal-backdrop" onClick={() => setSelected(null)}>
|
||
<div className="modal" onClick={(e) => e.stopPropagation()}>
|
||
<h3>{selected.name}</h3>
|
||
<p className="muted small">
|
||
rischio {riskLabel(selected.riskLevel)} · economia ×{selected.economyModifier} ·
|
||
polizia ×{selected.policePressure}
|
||
</p>
|
||
{cityEvents(selected.id).length > 0 && (
|
||
<div className="banner">
|
||
{cityEvents(selected.id).map((event) => (
|
||
<span key={event.id}>
|
||
⚡ {event.title}
|
||
{!event.city && ' (globale)'}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
{player?.currentCityId === selected.id ? (
|
||
<p className="muted">Ti trovi già in questa città.</p>
|
||
) : (
|
||
<button
|
||
type="button"
|
||
className="btn btn--primary"
|
||
disabled={busy || (player?.money ?? 0) < selected.travelCost}
|
||
onClick={() => void travelTo(selected)}
|
||
>
|
||
Viaggia · {formatMoney(selected.travelCost)}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|