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 { api } from '../api/api';
|
||||||
import { ApiError } from '../api/http';
|
import { ApiError } from '../api/http';
|
||||||
import type { City } from '../api/types';
|
import type { City, WorldEvent } from '../api/types';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import { useToast } from '../components/Toast';
|
import { useToast } from '../components/Toast';
|
||||||
import { formatMoney, riskLabel } from '../shared/format';
|
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() {
|
export function TravelScreen() {
|
||||||
const { player, setPlayer } = useAuth();
|
const { player, setPlayer } = useAuth();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [cities, setCities] = useState<City[]>([]);
|
const [cities, setCities] = useState<City[]>([]);
|
||||||
|
const [events, setEvents] = useState<WorldEvent[]>([]);
|
||||||
|
const [selected, setSelected] = useState<City | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
const reload = useCallback(async () => {
|
||||||
const res = await api.cities();
|
const [c, e] = await Promise.all([api.cities(), api.events()]);
|
||||||
setCities(res.cities);
|
setCities(c.cities);
|
||||||
|
setEvents(e.events);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void reload();
|
void reload();
|
||||||
}, [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) {
|
async function travelTo(city: City) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.travel(city.id);
|
const res = await api.travel(city.id);
|
||||||
setPlayer(res.player);
|
setPlayer(res.player);
|
||||||
toast(`Benvenuto a ${res.cityName} (−${formatMoney(res.cost)})`, 'success');
|
toast(`Benvenuto a ${res.cityName} (−${formatMoney(res.cost)})`, 'success');
|
||||||
|
setSelected(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
|
toast(err instanceof ApiError ? err.message : 'Errore imprevisto', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -36,34 +67,101 @@ export function TravelScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="screen">
|
<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">
|
<ul className="list">
|
||||||
{cities.map((city) => {
|
{cities.map((city) => {
|
||||||
const isCurrent = player?.currentCityId === city.id;
|
const isCurrent = player?.currentCityId === city.id;
|
||||||
const canAfford = (player?.money ?? 0) >= city.travelCost;
|
|
||||||
return (
|
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>
|
<div>
|
||||||
<strong>{city.name}</strong>
|
<strong>{city.name}</strong>
|
||||||
{isCurrent && <span className="badge badge--small">sei qui</span>}
|
{isCurrent && <span className="badge badge--small">sei qui</span>}
|
||||||
<p className="muted small">
|
{(eventsByCity.get(city.id) ?? []).length > 0 && <span> ⚡</span>}
|
||||||
rischio {riskLabel(city.riskLevel)} · economia ×{city.economyModifier}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{!isCurrent && (
|
<span className="muted small">
|
||||||
<button
|
{isCurrent ? '—' : formatMoney(city.travelCost)}
|
||||||
type="button"
|
</span>
|
||||||
className="btn btn--secondary btn--small"
|
|
||||||
disabled={busy || !canAfford}
|
|
||||||
onClick={() => void travelTo(city)}
|
|
||||||
>
|
|
||||||
Viaggia · {formatMoney(city.travelCost)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ul>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user