# NumPy Tutorial: Dal Tuo Sistema di Collisioni al Codice Ottimizzato

Questo documento spiega NumPy usando come esempio reale il sistema di collisioni di Mice!, confrontando il tuo approccio originale con la versione ottimizzata.

## Indice
1. [Introduzione: Il Problema delle Performance](#1-introduzione-il-problema-delle-performance)
2. [Cos'è NumPy e Perché Serve](#2-cosè-numpy-e-perché-serve)
3. [Concetti Base di NumPy](#3-concetti-base-di-numpy)
4. [Dal Tuo Codice a NumPy: Caso Pratico](#4-dal-tuo-codice-a-numpy-caso-pratico)
5. [Spatial Hashing: L'Algoritmo Intelligente](#5-spatial-hashing-lalgoritmo-intelligente)
6. [Operazioni Vettoriali in NumPy](#6-operazioni-vettoriali-in-numpy)
7. [Best Practices e Pitfalls](#7-best-practices-e-pitfalls)

---

## 1. Introduzione: Il Problema delle Performance

### Il Tuo Sistema Originale (Funzionava Bene!)

```python
# rats.py - Il tuo approccio originale
def update_maze(self):
    # Popolava dizionari con le posizioni delle unità
    self.unit_positions = {}
    self.unit_positions_before = {}
    
    for unit in self.units.values():
        unit.move()
        # Raggruppa unità per posizione
        self.unit_positions.setdefault(unit.position, []).append(unit)
        self.unit_positions_before.setdefault(unit.position_before, []).append(unit)
    
    for unit in self.units.values():
        unit.collisions()  # Ogni unità controlla le proprie collisioni
```

### Il Problema con 200+ Unità

Con 5-10 topi: **funziona perfetto** ✅  
Con 200+ topi: **FPS crollano** ❌

**Perché?**
- Ogni topo controlla collisioni con TUTTI gli altri topi
- 200 topi = 200 × 200 = **40,000 controlli per frame!**
- Complessità: **O(n²)** - cresce in modo quadratico

---

## 2. Cos'è NumPy e Perché Serve

### NumPy in 3 Parole
**Array multidimensionali ottimizzati**

### Perché è Veloce?

```python
# Python puro (lento ❌)
distances = []
for i in range(1000):
    for j in range(1000):
        dx = x[i] - y[j]
        dy = x[i] - y[j]
        distances.append((dx**2 + dy**2)**0.5)
# Tempo: ~500ms con 1 milione di operazioni

# NumPy (veloce ✅)
import numpy as np
distances = np.sqrt((x[:, None] - y[None, :])**2 + (x[:, None] - y[None, :])**2)
# Tempo: ~5ms - 100 volte più veloce!
```

### Perché la Differenza?

1. **Codice C Compilato**: NumPy è scritto in C/C++, non Python interpretato
2. **Operazioni Vettoriali**: Calcola migliaia di valori in parallelo
3. **Memoria Contigua**: Dati organizzati efficientemente in RAM
4. **CPU SIMD**: Usa istruzioni speciali della CPU per parallelismo hardware

---

## 3. Concetti Base di NumPy

### Array vs Liste Python

```python
# Lista Python (flessibile ma lenta)
lista = [1, 2, 3, 4, 5]
lista.append("sei")  # OK - tipi misti
lista[0] = "uno"     # OK - cambio tipo

# Array NumPy (veloce ma rigido)
import numpy as np
array = np.array([1, 2, 3, 4, 5])
# array[0] = "uno"  # ERRORE! Tipo fisso: int64
```

**Regola**: NumPy sacrifica flessibilità per velocità

### Operazioni Elemento per Elemento

```python
# Python puro
lista_a = [1, 2, 3]
lista_b = [4, 5, 6]
risultato = []
for a, b in zip(lista_a, lista_b):
    risultato.append(a + b)
# risultato = [5, 7, 9]

# NumPy (broadcasting)
array_a = np.array([1, 2, 3])
array_b = np.array([4, 5, 6])
risultato = array_a + array_b  # [5, 7, 9] - automatico!
```

### Broadcasting: Operazioni su Array di Dimensioni Diverse

```python
# Esempio reale dal tuo gioco: calcolare distanze
unit_positions = np.array([[10, 20], [30, 40], [50, 60]])  # 3 unità
target = np.array([25, 35])  # 1 bersaglio

# Vogliamo: distanza di ogni unità dal bersaglio
# Senza broadcasting (noioso):
distances = []
for pos in unit_positions:
    dx = pos[0] - target[0]
    dy = pos[1] - target[1]
    distances.append(np.sqrt(dx**2 + dy**2))

# Con broadcasting (elegante):
diff = unit_positions - target  # NumPy espande target automaticamente
distances = np.sqrt((diff**2).sum(axis=1))
# Output: [18.03, 7.07, 28.28]
```

**Come funziona?**
```
unit_positions:  [[10, 20],     target: [25, 35]
                  [30, 40],     
                  [50, 60]]     Broadcasting lo espande a:
                                        [[25, 35],
                                         [25, 35],
                                         [25, 35]]
```

---

## 4. Dal Tuo Codice a NumPy: Caso Pratico

### Fase 1: Il Tuo Approccio con i Dizionari

```python
# units/rat.py - Il tuo codice originale
def collisions(self):
    # Prende unità nella stessa cella
    units_here = self.game.unit_positions.get(self.position, [])
    units_before = self.game.unit_positions_before.get(self.position_before, [])
    
    # Controlla ogni unità
    for other_unit in units_here + units_before:
        if other_unit.id == self.id:
            continue
        
        # Logica di collisione...
        if self.sex == other_unit.sex and self.fight:
            self.die(other_unit)
        elif self.sex != other_unit.sex:
            self.fuck(other_unit)
```

**Pro del Tuo Approccio:**
- ✅ Semplice e leggibile
- ✅ Usa dizionari Python nativi
- ✅ Funziona perfettamente con poche unità

**Problema con 200+ Unità:**
- ❌ Ogni topo itera su liste di Python
- ❌ Controlli ripetuti (topo A controlla B, poi B controlla A)
- ❌ Nessuna ottimizzazione per distanze

### Fase 2: Spatial Hashing (L'Idea Geniale)

Prima di NumPy, serve un algoritmo migliore: **Spatial Hashing**

```python
# Concetto: dividi il mondo in "celle" (griglia)
# Ogni cella contiene solo le unità al suo interno

# Mondo di gioco:
#   0   1   2   3
# 0 [ ] [ ] [ ] [ ]
# 1 [ ] [A] [B] [ ]
# 2 [ ] [ ] [C] [ ]
# 3 [ ] [ ] [ ] [ ]

# Dizionario spatial hash:
spatial_grid = {
    (1, 1): [unit_A],
    (2, 1): [unit_B],
    (2, 2): [unit_C]
}

# Quando unit_A cerca collisioni:
# Controlla SOLO celle (1,1) e adiacenti (0,0), (0,1), (0,2), (1,0), (1,2), (2,0), (2,1), (2,2)
# Non controlla unit_C a (2,2) - troppo lontano!
```

**Vantaggio**: Da O(n²) a O(n)!
- 200 unità: da 40,000 controlli a ~1,800 controlli (celle adiacenti)

### Fase 3: NumPy per Calcoli Massivi

```python
# engine/collision_system.py - Il nuovo approccio

class CollisionSystem:
    def __init__(self, cell_size=32):
        # Pre-allocazione: prepara spazio per array NumPy
        self.unit_ids = np.zeros(100, dtype=np.int64)  # Array di ID
        self.bboxes = np.zeros((100, 4), dtype=np.float32)  # Array di bounding box
        self.positions = np.zeros((100, 2), dtype=np.int32)  # Array di posizioni
        self.current_size = 0  # Quante unità registrate
        self.capacity = 100  # Capacità massima prima di resize
```

**Perché Pre-allocazione?**
```python
# Cattivo: crescita lenta ❌
array = np.array([])
for i in range(1000):
    array = np.append(array, i)  # Crea NUOVO array ogni volta!
# Tempo: ~200ms

# Buono: pre-allocazione ✅
array = np.zeros(1000)
for i in range(1000):
    array[i] = i  # Modifica array esistente
# Tempo: ~2ms
```

### Fase 4: Registrazione Unità

```python
def register_unit(self, unit_id, bbox, position, position_before, collision_layer):
    """Registra un'unità nel sistema di collisione"""
    
    # Se array pieno, raddoppia capacità
    if self.current_size >= self.capacity:
        self._resize_arrays(self.capacity * 2)
    
    idx = self.current_size
    
    # Inserisci dati negli array NumPy
    self.unit_ids[idx] = unit_id
    self.bboxes[idx] = bbox  # [x1, y1, x2, y2]
    self.positions[idx] = position
    self.position_before[idx] = position_before
    self.layers[idx] = collision_layer.value
    
    # Spatial hashing: aggiungi a griglia
    cell = (position[0], position[1])
    self.spatial_grid[cell].append(idx)  # Salva INDICE, non unità
    
    self.current_size += 1
```

**Nota Importante**: Salviamo **indici** negli array, non oggetti Python!
- `spatial_grid[(5, 10)] = [0, 3, 7]` → Unità agli indici 0, 3, 7 degli array NumPy
- Accesso veloce: `self.bboxes[0]`, `self.bboxes[3]`, `self.bboxes[7]`

### Fase 5: Collisioni Vettoriali con NumPy

```python
def get_collisions_for_unit(self, unit_id, bbox, collision_layer):
    """Trova tutte le collisioni per un'unità"""
    
    # 1. Trova celle da controllare (spatial hashing)
    x, y = bbox[0] // self.cell_size, bbox[1] // self.cell_size
    cells_to_check = [
        (x-1, y-1), (x, y-1), (x+1, y-1),
        (x-1, y),   (x, y),   (x+1, y),
        (x-1, y+1), (x, y+1), (x+1, y+1)
    ]
    
    # 2. Raccogli candidati da celle adiacenti
    candidates = []
    for cell in cells_to_check:
        candidates.extend(self.spatial_grid.get(cell, []))
    
    if len(candidates) < 10:
        # POCHI candidati: usa Python normale
        collisions = []
        for idx in candidates:
            if self.unit_ids[idx] == unit_id:
                continue
            if self._check_bbox_collision(bbox, self.bboxes[idx]):
                collisions.append((self.layers[idx], self.unit_ids[idx]))
        return collisions
    
    else:
        # MOLTI candidati: USA NUMPY! ✨
        return self._vectorized_collision_check(unit_id, bbox, candidates)
```

### Fase 6: La Magia di NumPy - Vectorized Collision Check

```python
def _vectorized_collision_check(self, unit_id, bbox, candidate_indices):
    """Controlla collisioni usando NumPy per massima velocità"""
    
    # Converti candidati in array NumPy
    candidate_indices = np.array(candidate_indices, dtype=np.int32)
    
    # Filtra l'unità stessa (non collidere con se stessi)
    mask = self.unit_ids[candidate_indices] != unit_id
    candidate_indices = candidate_indices[mask]
    
    if len(candidate_indices) == 0:
        return []
    
    # Estrai bounding box di TUTTI i candidati in un colpo solo
    candidate_bboxes = self.bboxes[candidate_indices]  # Shape: (N, 4)
    # candidate_bboxes = [[x1, y1, x2, y2],  # candidato 0
    #                     [x1, y1, x2, y2],  # candidato 1
    #                     ...]
    
    # Controllo collisione AABB (Axis-Aligned Bounding Box)
    # Due rettangoli collidono se:
    # - bbox.x1 < other.x2  AND
    # - bbox.x2 > other.x1  AND
    # - bbox.y1 < other.y2  AND
    # - bbox.y2 > other.y1
    
    # NumPy calcola TUTTE le collisioni contemporaneamente! 🚀
    colliding_mask = (
        (bbox[0] < candidate_bboxes[:, 2]) &  # bbox.x1 < others.x2
        (bbox[2] > candidate_bboxes[:, 0]) &  # bbox.x2 > others.x1
        (bbox[1] < candidate_bboxes[:, 3]) &  # bbox.y1 < others.y2
        (bbox[3] > candidate_bboxes[:, 1])    # bbox.y2 > others.y1
    )
    # colliding_mask = [True, False, True, False, True, ...]
    
    # Filtra solo unità che collidono
    colliding_indices = candidate_indices[colliding_mask]
    
    # Restituisci coppie (layer, unit_id)
    return list(zip(
        self.layers[colliding_indices],
        self.unit_ids[colliding_indices]
    ))
```

**Spiegazione Dettagliata del Codice NumPy:**

```python
# Esempio concreto con 3 candidati
bbox = [10, 20, 30, 40]  # Nostro topo: x1=10, y1=20, x2=30, y2=40

candidate_bboxes = np.array([
    [5,  15, 25, 35],   # Candidato 0
    [50, 60, 70, 80],   # Candidato 1 (lontano)
    [15, 25, 35, 45]    # Candidato 2
])

# Controllo bbox[0] < candidate_bboxes[:, 2]
# bbox[0] = 10
# candidate_bboxes[:, 2] = [25, 70, 35]  # Colonna x2 di tutti i candidati
# 10 < [25, 70, 35] = [True, True, True]

# Controllo bbox[2] > candidate_bboxes[:, 0]
# bbox[2] = 30
# candidate_bboxes[:, 0] = [5, 50, 15]  # Colonna x1
# 30 > [5, 50, 15] = [True, False, True]

# ... altri controlli ...

# Combinazione finale (AND logico):
colliding_mask = [True, False, True]  # Solo 0 e 2 collidono!
```

---

## 5. Spatial Hashing: L'Algoritmo Intelligente

### Visualizzazione Pratica

```
Mondo di gioco 640x480, cell_size=32

Griglia spaziale:
     0    1    2    3    4    5   ...  19
  ┌────┬────┬────┬────┬────┬────┬────┬────┐
0 │    │    │    │    │    │    │    │    │
  ├────┼────┼────┼────┼────┼────┼────┼────┤
1 │    │ R1 │ R2 │    │    │    │    │    │  R = Rat
  ├────┼────┼────┼────┼────┼────┼────┼────┤  B = Bomb
2 │    │ R3 │ B1 │ R4 │    │    │    │    │  M = Mine
  ├────┼────┼────┼────┼────┼────┼────┼────┤
3 │    │    │ M1 │    │    │    │    │    │
  └────┴────┴────┴────┴────┴────┴────┴────┘
```

### Come Funziona il Lookup

```python
# R1 cerca collisioni da cella (1, 1)
def get_collisions_for_unit(self, unit_id, bbox):
    x, y = 1, 1  # Posizione R1
    
    # Controlla 9 celle (3x3 centrato su R1):
    cells = [
        (0,0), (1,0), (2,0),  # Riga sopra
        (0,1), (1,1), (2,1),  # Riga centrale (include R1)
        (0,2), (1,2), (2,2)   # Riga sotto
    ]
    
    # spatial_grid è un dizionario:
    # {
    #   (1, 1): [idx_R1],
    #   (2, 1): [idx_R2],
    #   (1, 2): [idx_R3],
    #   (2, 2): [idx_B1, idx_R4],
    #   (2, 3): [idx_M1]
    # }
    
    candidates = []
    for cell in cells:
        candidates.extend(self.spatial_grid.get(cell, []))
    
    # candidates = [idx_R1, idx_R2, idx_R3, idx_B1, idx_R4]
    # NON include idx_M1 perché (2,3) è fuori dal range 3x3!
```

### Benefici Misurabili

```python
# SENZA spatial hashing (O(n²)):
# 200 unità → 200 × 200 = 40,000 controlli

# CON spatial hashing (O(n)):
# 200 unità, distribuite su 20×15=300 celle
# Media 0.67 unità per cella
# Ogni unità controlla 9 celle × 0.67 = ~6 candidati
# 200 unità × 6 candidati = 1,200 controlli
# 
# Miglioramento: 40,000 → 1,200 = 33x più veloce! 🚀
```

---

## 6. Operazioni Vettoriali in NumPy

### Broadcasting Avanzato: Esplosioni

```python
def get_units_in_area(self, positions, layer_filter=None):
    """Trova unità in un'area (es. esplosione bomba)"""
    
    # positions: lista di posizioni esplose
    # es. [(10, 10), (10, 11), (11, 10), (11, 11)]  # Esplosione 2x2
    
    if self.current_size == 0:
        return []
    
    # Converti in array NumPy
    area_positions = np.array(positions, dtype=np.int32)  # Shape: (4, 2)
    
    # Prendi posizioni di TUTTE le unità
    all_positions = self.positions[:self.current_size]  # Shape: (N, 2)
    # es. all_positions = [[5, 5], [10, 10], [15, 15], [10, 11], ...]
    
    # Broadcasting trick per confrontare OGNI posizione esplosione con OGNI unità
    # area_positions[:, None, :] → Shape: (4, 1, 2)
    # all_positions[None, :, :] → Shape: (1, N, 2)
    # Risultato → Shape: (4, N, 2) - tutte le combinazioni!
    
    matches = (area_positions[:, None, :] == all_positions[None, :, :]).all(axis=2)
    # matches[i, j] = True se esplosione i colpisce unità j
    
    # any(axis=0): almeno una posizione esplosione colpisce quella unità?
    unit_hit_mask = matches.any(axis=0)  # Shape: (N,)
    
    # Filtra per layer se richiesto
    if layer_filter:
        valid_layers = self.layers[:self.current_size] == layer_filter.value
        unit_hit_mask = unit_hit_mask & valid_layers
    
    # Restituisci ID delle unità colpite
    hit_indices = np.where(unit_hit_mask)[0]
    return self.unit_ids[hit_indices].tolist()
```

**Spiegazione con Esempio Concreto:**

```python
# Bomba esplode creando 4 celle di fuoco
area_positions = np.array([[10, 10], [10, 11], [11, 10], [11, 11]])

# Ci sono 3 topi nel gioco
all_positions = np.array([[5, 5], [10, 10], [10, 11]])

# Broadcasting:
area_positions[:, None, :].shape  # (4, 1, 2)
all_positions[None, :, :].shape   # (1, 3, 2)

# Confronto elemento per elemento:
matches = (area_positions[:, None, :] == all_positions[None, :, :]).all(axis=2)
# matches = [
#   [False, False, False],  # Esplosione (10,10) vs [(5,5), (10,10), (10,11)]
#   [False, True,  False],  # Esplosione (10,11) vs ...
#   [False, False, True ],  # Esplosione (11,10) vs ...
#   [False, False, False]   # Esplosione (11,11) vs ...
# ]

# Collassa su asse 0 (almeno UNA esplosione colpisce?)
unit_hit_mask = matches.any(axis=0)  # [False, True, True]
# Topo 0 (5,5): NON colpito
# Topo 1 (10,10): COLPITO (da esplosione 0)
# Topo 2 (10,11): COLPITO (da esplosione 1)
```

### Calcolo Distanze Vettoriale

```python
# Esempio: trovare tutti i topi entro raggio 50 pixel da una bomba

bomb_position = np.array([100, 100])  # Posizione bomba

# Posizioni di tutti i topi (array NumPy)
rat_positions = self.positions[:self.current_size]  # Shape: (N, 2)

# Calcolo distanze usando broadcasting
diff = rat_positions - bomb_position  # Shape: (N, 2)
# diff[i] = [rat_x - bomb_x, rat_y - bomb_y]

distances = np.sqrt((diff ** 2).sum(axis=1))  # Shape: (N,)
# distances[i] = sqrt((dx)^2 + (dy)^2)

# Trova topi entro raggio
within_radius = distances < 50  # Boolean mask
hit_rat_indices = np.where(within_radius)[0]

# Esempio output:
# rat_positions = [[90, 90], [110, 110], [200, 200]]
# diff = [[-10, -10], [10, 10], [100, 100]]
# distances = [14.14, 14.14, 141.42]
# within_radius = [True, True, False]
# hit_rat_indices = [0, 1]  # Primi due topi colpiti!
```

---

## 7. Best Practices e Pitfalls

### ✅ Quando Usare NumPy

```python
# BUONO: Operazioni su molti dati
positions = np.array([[...] for _ in range(1000)])
distances = np.sqrt(((positions - target)**2).sum(axis=1))

# CATTIVO: Operazioni su pochi dati (overhead NumPy!)
positions = np.array([[10, 20], [30, 40]])  # Solo 2 elementi
distances = np.sqrt(((positions - target)**2).sum(axis=1))
# Più lento di un semplice loop Python!
```

**Regola nel tuo codice:**
```python
if len(candidates) < 10:
    # Usa Python normale
    for idx in candidates:
        ...
else:
    # Usa NumPy
    self._vectorized_collision_check(...)
```

### ✅ Pre-allocazione vs Append

```python
# CATTIVO ❌ (lento con array grandi)
array = np.array([])
for i in range(10000):
    array = np.append(array, i)  # O(n) ad ogni append!

# BUONO ✅ (veloce)
array = np.zeros(10000)
for i in range(10000):
    array[i] = i  # O(1) ad ogni assegnazione

# MIGLIORE ✅ (senza loop!)
array = np.arange(10000)  # Operazione vettoriale nativa
```

### ✅ Memory Layout e Performance

```python
# Row-major (C order) - default NumPy
array = np.zeros((1000, 3), order='C')
# Memoria: [x0, y0, z0, x1, y1, z1, ...]
# Veloce per accesso righe: array[i, :]

# Column-major (Fortran order)
array = np.zeros((1000, 3), order='F')
# Memoria: [x0, x1, ..., y0, y1, ..., z0, z1, ...]
# Veloce per accesso colonne: array[:, j]

# Nel tuo caso (posizioni):
self.positions = np.zeros((100, 2))  # Row-major è perfetto
# Accesso frequente: self.positions[idx] → [x, y] di un'unità
```

### ⚠️ Pitfall Comuni

#### 1. Copy vs View

```python
# View (condivide memoria)
a = np.array([1, 2, 3])
b = a[:]  # b è una VIEW di a
b[0] = 999
print(a)  # [999, 2, 3] - modificato anche a!

# Copy (memoria separata)
a = np.array([1, 2, 3])
b = a.copy()
b[0] = 999
print(a)  # [1, 2, 3] - a è immutato
```

**Nel tuo codice:**
```python
def get_collisions_for_unit(self, ...):
    # Filtriamo candidati
    candidate_indices = candidate_indices[mask]  # Crea VIEW
    
    # Se modifichi candidate_indices dopo, potresti modificare l'originale!
    # Soluzione: .copy() se necessario
```

#### 2. Broadcasting Inatteso

```python
a = np.array([1, 2, 3])
b = np.array([[1], [2], [3]])

# Cosa succede?
result = a + b
# Broadcasting espande a:
# [[1, 2, 3],     [[1], [1], [1]]     [[2, 3, 4],
#  [1, 2, 3],  +   [2], [2], [2]]  =   [3, 4, 5],
#  [1, 2, 3]]      [3], [3], [3]]      [4, 5, 6]]
```

**Verifica sempre le shape:**
```python
print(f"Shape: {array.shape}")  # Sempre prima di operazioni complesse!
```

#### 3. Integer Overflow

```python
# ATTENZIONE con dtype piccoli!
a = np.array([250], dtype=np.uint8)  # Max 255
b = a + 10  # 260, ma uint8 wrap: diventa 4!

# Soluzione: usa dtype appropriati
a = np.array([250], dtype=np.int32)  # Max ~2 miliardi
b = a + 10  # 260 ✅
```

---

## Confronto Finale: Prima vs Dopo

### Codice Originale (Il Tuo)

```python
# rats.py
def update_maze(self):
    self.unit_positions = {}
    self.unit_positions_before = {}
    
    for unit in self.units.values():
        unit.move()
        self.unit_positions.setdefault(unit.position, []).append(unit)
        self.unit_positions_before.setdefault(unit.position_before, []).append(unit)
    
    for unit in self.units.values():
        unit.collisions()

# units/rat.py
def collisions(self):
    for other_unit in self.game.unit_positions.get(self.position, []):
        if other_unit.id == self.id:
            continue
        # Controlla collisione...
```

**Complessità**: O(n²) nel caso peggiore  
**Performance**: ~50ms con 200 unità  
**Memoria**: Dizionari Python + liste di oggetti

### Codice Ottimizzato (NumPy)

```python
# rats.py - 4-pass loop
def update_maze(self):
    self.collision_system.clear()
    
    # Pass 1: Pre-registra posizioni
    for unit in self.units.values():
        self.collision_system.register_unit(unit.id, unit.bbox, ...)
    
    # Pass 2: Movimento
    for unit in self.units.values():
        unit.move()
    
    # Pass 3: Ri-registra dopo movimento
    self.collision_system.clear()
    for unit in self.units.values():
        self.collision_system.register_unit(unit.id, unit.bbox, ...)
    
    # Pass 4: Collisioni
    for unit in self.units.values():
        unit.collisions()

# units/rat.py
def collisions(self):
    collisions = self.game.collision_system.get_collisions_for_unit(
        self.id, self.bbox, self.collision_layer
    )
    for _, other_id in collisions:
        other_unit = self.game.get_unit_by_id(other_id)
        if isinstance(other_unit, Rat):
            # Controlla collisione...
```

**Complessità**: O(n) con spatial hashing + NumPy  
**Performance**: ~3ms con 250 unità (16x più veloce!)  
**Memoria**: Array NumPy pre-allocati (più efficiente)

---

## Conclusione

### Cosa Hai Imparato

1. **NumPy**: Array veloci per operazioni matematiche massive
2. **Broadcasting**: Operazioni automatiche su array di dimensioni diverse
3. **Spatial Hashing**: Ridurre O(n²) a O(n) con partizionamento spaziale
4. **Vectorization**: Sostituire loop Python con operazioni NumPy parallele
5. **Pre-allocazione**: Evitare allocazioni ripetute per velocità

### Quando Applicare Queste Tecniche

- ✅ **Usa NumPy quando**: Hai 50+ elementi da processare con operazioni matematiche
- ✅ **Usa Spatial Hashing quando**: Controlli collisioni/prossimità in spazio 2D/3D
- ✅ **Usa Vectorization quando**: Stesso calcolo ripetuto su molti dati
- ❌ **Non usare quando**: Pochi elementi (< 10) o logica complessa non matematica

### Risorse per Approfondire

- **NumPy Documentation**: https://numpy.org/doc/stable/
- **NumPy Quickstart**: https://numpy.org/doc/stable/user/quickstart.html
- **Broadcasting**: https://numpy.org/doc/stable/user/basics.broadcasting.html
- **Performance Tips**: https://numpy.org/doc/stable/user/c-info.performance.html

---

**Il tuo codice originale era ottimo per il caso d'uso iniziale.** L'ottimizzazione con NumPy è stata necessaria solo quando hai scalato a 200+ unità. Questo è un esempio perfetto di "ottimizza quando serve", non prematuramente! 🎯
