client: travel screen — SVG Italy map with clickable city markers
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>
This commit is contained in:
@@ -1,32 +1,63 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { api } from '../api/api';
|
||||
import { ApiError } from '../api/http';
|
||||
import type { City } from '../api/types';
|
||||
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 res = await api.cities();
|
||||
setCities(res.cities);
|
||||
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 {
|
||||
@@ -36,34 +67,101 @@ export function TravelScreen() {
|
||||
|
||||
return (
|
||||
<div className="screen">
|
||||
<h2 className="screen__title">Città</h2>
|
||||
<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;
|
||||
const canAfford = (player?.money ?? 0) >= city.travelCost;
|
||||
return (
|
||||
<li key={city.id} className={`list__row${isCurrent ? ' is-current' : ''}`}>
|
||||
<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>}
|
||||
<p className="muted small">
|
||||
rischio {riskLabel(city.riskLevel)} · economia ×{city.economyModifier}
|
||||
</p>
|
||||
{(eventsByCity.get(city.id) ?? []).length > 0 && <span> ⚡</span>}
|
||||
</div>
|
||||
{!isCurrent && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--secondary btn--small"
|
||||
disabled={busy || !canAfford}
|
||||
onClick={() => void travelTo(city)}
|
||||
>
|
||||
Viaggia · {formatMoney(city.travelCost)}
|
||||
</button>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user