Repository navigation
CustomPlatformerSlopes Movimento e Collisioni
Torna alla panoramica · Scena e risorse · Entità e gameplay
Il problema delle rampe è far camminare un corpo AABB, che rimane verticale, su un segmento inclinato. La strategia del prototipo consiste nel calcolare prima lo spostamento necessario a seguire la superficie, sottoporlo alle collisioni con il resto del mondo e infine ripristinare lo stato fisico dell'oggetto appoggiato.
La pendenza non viene trattata come un blocco rettangolare e non si applica un generico solver per corpi rigidi. Si impone un vincolo geometrico ai piedi dell'oggetto, con regole di movimento arcade. Questo comportamento appartiene a DynamicObject: lo ereditano Mario, nemici e power-up.
Le figure sono tratte dalla relazione Slope in SimplePlatformer, fornita dall'autore. La spiegazione qui è adattata al codice di CustomPlatformerSlopes, che aggiunge scivolamento, collisioni discrete e gestione dell'inizio/fine dei contatti. Le sigle P, R e U nelle figure identificano i blocchi della relazione, non i numeri di riga di questo prototipo.
In tutte le figure y cresce verso il basso. Un valore di y minore indica quindi una superficie più alta. Le misure sono in unità di scena; una unità corrisponde alla dimensione nominale di un blocco, non a un pixel.
Slope riceve un segmento inclinato (LineF) e calcola internamente il rettangolo che lo contiene e _risesRight: true indica una salita verso destra. Il verso non dipende dall'ordine degli estremi. Nell'editor la rampa è una linea di categoria Slope, senza un campo JSON risesRight. Solo il segmento inclinato sostiene il corpo: base e lato verticale dell'ingombro non sono superfici solide. Nel gioco il collider è trasparente e la grafica proviene dallo sfondo; i triangoli nelle figure servono alla spiegazione geometrica.
Slope::sweep() e Slope::overlap() restituiscono false: il rettangolo che delimita la rampa non deve creare collisioni né nel percorso CCD né in quello discreto. Il contatto con il segmento viene risolto esplicitamente da DynamicObject.
Nelle figure il corpo iniziale è azzurro pieno, il risultato è blu tratteggiato, il risultato senza un determinato passaggio è rosso tratteggiato, il piano ancora da correggere è grigio e il piede a monte è arancione.
overlapsX(body) verifica l'intersezione stretta delle proiezioni sull'asse x:
body.right() > rect().left() && body.left() < rect().right()Toccare un estremo senza sovrapporsi non basta. Questo test non dice ancora che il corpo poggia sulla rampa: serve anche il confronto fra i piedi e la superficie.
Un AABB non ruota per seguire la pendenza. Per tenerlo interamente sopra il segmento, si campiona la superficie sotto l'angolo inferiore rivolto a monte: il destro se la rampa sale a destra, il sinistro nel caso opposto. Campionare al centro lascerebbe il lato a monte dentro la rampa.
surfaceY(body) calcola:
float footX = _risesRight ? body.right() : body.left();
float x = std::max(rect().left(), std::min(footX, rect().right()));
float t = (x - rect().left()) / rect().size.x;
return _risesRight ? rect().bottom() - t * rect().size.y
: rect().top() + t * rect().size.y;Il clamp è parte della geometria dei raccordi: quando il piede supera un estremo, si usa la quota dell'estremo, anziché prolungare indefinitamente la retta inclinata. Questo permette di entrare da un piano, arrivare sulla sommità e uscire dalla rampa. Il clamp non crea un pavimento infinito: overlapsX stabilisce separatamente se esiste ancora supporto.
| Classe | Responsabilità |
|---|---|
MovableObject |
Integra gravità, accelerazioni, attrito e salto. Conserva velocità corrente e precedente. |
Slope |
Risponde alle domande geometriche e fornisce fattore di velocità e velocità di scivolamento. |
DynamicObject |
Pianifica il movimento sulla rampa e conferma o scarta il supporto dopo le altre collisioni. |
CollidableObject |
Esegue CCD o risoluzione discreta, registra i contatti e rileva quelli terminati. |
Platform |
Filtra i contatti con le piattaforme attraversabili usando la posizione iniziale del corpo. |
flowchart TD
A[MovableObject::update: accelerazioni e movimento libero] --> B[Annulla il movimento libero]
B --> C[prepareMovement: reset e posizione iniziale]
C --> D[resolveSlopeCollisions: pianifica il percorso sulla rampa]
D --> E[Salva velocità verticale e quota pianificate]
E --> F{CCD attivo?}
F -->|Sì| G[CCD: risolvi le velocità, poi muovi]
F -->|No| H[Muovi, rileva sovrapposizioni e correggi la posizione]
G --> I[finishMovement: verifica il supporto e finalizza lo stato]
H --> I
I --> J[detectDecollisions: notifica i contatti terminati]
CollidableObject::update() usa questo ordine per entrambi i percorsi. dt <= 0 interrompe l'aggiornamento; la fisica congelata non entra nei passaggi delle rampe.
Il movimento libero viene annullato perché la scelta del supporto e il CCD devono partire dalla posizione iniziale. Preparare il movimento e confermare il contatto sono due operazioni diverse: tra le due, un muro può ridurre lo spostamento, un lift può intercettare la discesa o una callback può far rimbalzare l'oggetto.
| Nel prototipo didattico | In CustomPlatformerSlopes |
|---|---|
SlopeObject |
Slope |
Prima parte di resolveCollisions()
|
prepareMovement() e resolveSlopeCollisions()
|
| Solver CCD della classe base | CCD oppure rilevazione e risoluzione AABB discreta |
Controlli dopo il CCD e finalizzazione in update()
|
finishMovement(), dopo il movimento effettivo |
Scrittura di _rect.pos
|
setPos(), per notificare anche l'indice spaziale della scena |
| Notifiche del contatto | Registrazione dei metadati, notifiche begin e successivo controllo dei contatti terminati |
Il nucleo geometrico conserva la stessa suddivisione e gli stessi nomi del refactoring didattico: curRect, nextRect, curSlope, contactY, _slope, _x_vel_factor.
prepareMovement() azzera lo stato temporaneo e memorizza _movementStartPos. Se le collisioni sono abilitate, chiama resolveSlopeCollisions().
Se _vel.y < 0, resolveSlopeCollisions() termina subito. La velocità negativa in questo punto rappresenta un salto o un altro moto verso l'alto, non la salita geometrica lungo una rampa: quest'ultima viene azzerata alla fine di ogni contatto confermato.
Questa scelta permette anche di attraversare il triangolo dal basso. Senza la distinzione fra velocità del salto e velocità temporanea lungo la rampa, l'oggetto verrebbe riagganciato alla superficie mentre tenta di saltare.
La prima scansione trova curSlope, cioè la rampa che sostiene il corpo prima del passo. Sono necessari sovrapposizione in x e distanza verticale entro contactTolerance = 0.01 unità di scena:
std::abs(curRect.bottom() - slope->surfaceY(curRect)) <= contactToleranceIl codice considera solo rampe vive, collidibili e accettate da entrambi gli oggetti tramite collidableWith. La prima rampa valida fissa il supporto corrente. Un corpo in caduta può non avere curSlope e tuttavia atterrare su una rampa durante il passo.
curSlope e _slope non sono sinonimi: la prima guida la partenza del movimento; la seconda sarà il supporto candidato all'arrivo, eventualmente diverso in una valle.
Con pendenza s = height / width, il fattore orizzontale è:
| Movimento effettivo | _x_vel_factor |
|---|---|
| Salita | 1 / (1 + s) |
| Discesa | 1 + s |
| Velocità nulla o nessun supporto corrente | 1 |
È una regola di gameplay, non una legge di conservazione dell'energia. Il verso viene dedotto da _vel.x, non dal comando: durante una frenata l'oggetto può ancora muoversi nel verso opposto all'input.
Il fattore agisce sullo spostamento del passo corrente:
_vel.x *= _x_vel_factor;
RectF nextRect = curRect + _vel * dt;Nel prototipo GitHub, prima di costruire nextRect, si aggiungono due regole:
-
Rilascio in salita: una decelerazione aggiuntiva di
60 * (1 + s)unità di scena/s² smorza l'inerzia residua. Il modulo viene limitato a zero per evitare che la frenata inverta da sola il verso. -
Scivolamento passivo: se c'è supporto, nessun comando e velocità orizzontale nulla, si usa temporaneamente
Slope::slideVelocity(). Un comando ha precedenza e permette di ripartire in salita da fermi.
La velocità passiva lungo la superficie è 5 * min(s, 2) unità di scena/s, quindi al massimo 10. La sua componente orizzontale ha modulo:
5 * std::min(gradient, 2.0f) / std::sqrt(1.0f + gradient * gradient)Il segno è sempre verso valle. Non si accumula un'accelerazione gravitazionale tangenziale: anche questa è una scelta arcade. _slope_sliding distingue questo spostamento dalla velocità comandata, così da rimuoverlo completamente dopo il passo.
La prima previsione di nextRect usa la velocità scalata, ma la sua y contiene ancora il movimento libero. Se esiste curSlope, si porta il fondo del rettangolo candidato sulla sua superficie:
nextRect.pos.y += curSlope->surfaceY(nextRect) - nextRect.bottom();Da uno stato appoggiato, lo spostamento verticale prodotto dalla gravità in un passo è circa g * dt², mentre il dislivello della rampa dipende da s * dx. Non c'è motivo che coincidano. Affidarsi alla sola gravità può quindi lasciare il corpo sospeso durante una discesa.
Qui cambia soltanto il rettangolo candidato, non ancora la posizione reale. La previsione aderente alla rampa serve anche per verificare correttamente l'arrivo su una pendenza adiacente.
La seconda scansione considera le rampe sovrapposte in x a nextRect. Per ciascuna calcola curSurfaceY, nextSurfaceY e onSurface, che indica se i piedi erano già sulla superficie entro tolleranza.
La condizione completa è:
curRect.bottom() <= curSurfaceY + contactTolerance &&
(nextRect.bottom() >= nextSurfaceY || onSurface) &&
nextSurfaceY < contactYLe tre parti hanno responsabilità diverse:
- Il corpo deve provenire da sopra o dalla superficie: un attraversamento dal basso non crea appoggio.
- I piedi devono raggiungere la nuova quota, oppure il contatto deve già esistere e venire mantenuto.
- Fra le candidate si sceglie la superficie più alta, cioè la y minore.
contactYparte daINFINITY.
Questo copre l'atterraggio dopo un salto e il mantenimento dell'appoggio. Ai raccordi, il campionamento limitato agli estremi permette anche l'ingresso da un piano.
Nella valle il corpo scende lungo A, ma la sua larghezza può già portare il piede opposto sopra B. La previsione del passaggio precedente lo colloca su A; il confronto mostra che B viene raggiunta ed è più alta, quindi il nuovo supporto diventa B. Usare la previsione di caduta libera può non rilevare questo attraversamento e lasciare selezionata A, portando il corpo dentro B.
Scivolamento passivo nella valle. Se la superficie scelta richiederebbe di salire (contactY < curRect.bottom()), il prototipo annulla lo spostamento orizzontale e mantiene il supporto precedente. Il corpo si arresta vicino al fondo, invece di cambiare rampa e invertire il verso di scivolamento a ogni passo. Questa regola si applica allo scivolamento passivo, non al movimento comandato.
Quando nessuna rampa accetta nextRect, ma esisteva curSlope, si mantiene quest'ultima come candidata per calcolare la quota finale limitata all'estremo:
_slope = curSlope;
contactY = curSlope->surfaceY(nextRect);Il passo può così arrivare esattamente alla quota del pavimento o della sommità. Questo non implica un contatto permanente oltre la rampa. In CustomPlatformerSlopes, finishMovement() registra il supporto e azzera la velocità verticale soltanto se alla posizione finale rimane sovrapposizione orizzontale. Uscendo completamente dal segmento, il corpo prosegue senza quel supporto e la gravità continua nei passi successivi.
La geometria ha prodotto una quota desiderata per i piedi. Per utilizzare il percorso di movimento esistente si converte il dislivello in velocità:
_vel.y = (contactY - curRect.bottom()) / dt;Ad esempio, con dt = 0.01, pendenza s = 0.5 e velocità orizzontale memorizzata 3, in salita il fattore è 2/3: si percorrono dx = 0.02 e dy = -0.01. Per questo passo _vel.y vale quindi -1. Il corpo sta camminando sulla superficie: quel -1 non è un impulso di salto.
prepareMovement() salva _slope_vel_y e _slope_bottom, rispettivamente velocità verticale e quota dei piedi pianificate. Servono a confrontare il progetto del movimento con il risultato delle altre collisioni. Nel CCD può cambiare la velocità; nella risoluzione discreta può cambiare direttamente la posizione.
Un blocco statico alla quota della sommità continua la superficie della rampa. Lo spostamento è però una singola diagonale verso la quota campionata: quando il campionamento raggiunge il clamp, tale diagonale può tagliare lo spigolo del blocco e incontrarne il fianco.
DynamicObject::collidableWith() riconosce il raccordo con tre condizioni leggibili:
-
sameTop: blocco e rampa hanno la stessa quota superiore; -
atHighEnd: il blocco si trova dal lato alto della rampa; -
adjacent: i rispettivi bordi orizzontali coincidono entrojoinTolerance.
Se tutte sono vere, il blocco è escluso dai test per quel contatto candidato. Viene escluso l'intero oggetto, non soltanto una sua faccia: anche le normali notifiche di collisione con quel blocco vengono saltate in quel passo. È un'assunzione di costruzione del livello, non una regola universale per qualsiasi geometria sovrapposta.
Nel prototipo GitHub questa eccezione riguarda soltanto blocchi statici non compenetrabili. Le altre slope, i trigger e le Platform attraversabili mantengono le proprie regole. Le pareti che non soddisfano il raccordo restano solide.
Il movimento diagonale viene ora sottoposto al CCD oppure alla risoluzione discreta. Nel percorso CCD, la correzione usa direttamente _vel, evitando velAdd() e il relativo limite di velocità: una velocità temporaneamente aumentata dalla discesa non deve essere troncata come se fosse la velocità memorizzata.
In finishMovement() il supporto rimane una candidatura da verificare. La fisica potrebbe essere stata disabilitata, la rampa eliminata o una collisione potrebbe aver prodotto un rimbalzo. In questi casi l'allineamento alla rampa non deve sovrascrivere la risposta di gioco.
Se il corpo doveva scendere ma viene fermato prima, si scarta _slope. In CCD può emergere già dal confronto _vel.y < _slope_vel_y - velTolerance; il controllo sulla quota finale bottom < _slope_bottom - contactTolerance copre anche le correzioni di posizione del percorso discreto.
La posizione ottenuta dalle collisioni rimane valida. Riallineare comunque i piedi alla rampa significherebbe spostarli attraverso la piattaforma.
Durante una salita, x e y sono legate dal vincolo geometrico. Se un soffitto impedisce di raggiungere la quota prevista, fermare soltanto y permetterebbe al corpo di avanzare orizzontalmente dentro la rampa.
finishMovement() riconosce il contatto verso l'alto oppure una quota finale più bassa di quella pianificata; ripristina _movementStartPos, azzera entrambe le componenti e scarta _slope. Qui il movimento è già avvenuto, quindi serve anche il ripristino della posizione, oltre all'azzeramento della velocità.
La prima operazione di finishMovement() è:
_vel.x = _slope_sliding ? 0 : _vel.x / _x_vel_factor;Si divide la velocità risultante, non si ripristina una copia di quella iniziale: così si conserva un'eventuale riduzione prodotta da un muro. Lasciare applicato il fattore significherebbe riapplicarlo al risultato precedente in ogni aggiornamento.
La velocità dello scivolamento passivo viene invece rimossa completamente. Se venisse conservata, l'attrito del passo successivo la tratterebbe come inerzia comandata e potrebbe introdurre effetti indesiderati, soprattutto dopo uno scivolamento bloccato. Mario usa _slope_sliding insieme allo spostamento effettivo per animare il cammino durante lo scivolamento.
Il dislivello era stato calcolato per tutto il movimento orizzontale pianificato. Un muro può ridurre soltanto la componente x: la quota inizialmente prevista non corrisponde più alla posizione finale.
Se la rampa è ancora valida e sovrapposta in x, si ricampiona la superficie usando il collider finale:
RectF curRect = sceneCollider();
setPos(pos() + PointF(0, _slope->surfaceY(curRect) - curRect.bottom()));Questo evita il galleggiamento in salita e la compenetrazione in discesa. setPos() mantiene informata la scena e il suo indice spaziale; non si modifica direttamente il rettangolo privato.
Dopo l'allineamento viene eseguita questa istruzione per ogni contatto con la rampa confermato, anche quando non ci sono muri:
_vel.y = 0;Durante il passo, la componente verticale codificava il dislivello necessario a seguire la rampa. Dopo il passo deve rappresentare lo stato di un corpo appoggiato.
Se rimanesse negativa durante una salita, midair() potrebbe interpretare il corpo come in volo e il successivo controllo all'ingresso di resolveSlopeCollisions() potrebbe scambiarla per un salto. Se rimanesse positiva durante una discesa, l'oggetto risulterebbe in aria e il salto da terra potrebbe essere rifiutato.
La simulazione nella figura appartiene al prototipo del Drive: usa dt = 0.01, gravità 24 e accelerazione orizzontale 12. Illustra il motivo dell'azzeramento; non descrive quantitativamente la traiettoria di Mario in questo prototipo, che usa altri parametri.
Il ripristino dello stato verticale è quindi distinto dal riallineamento geometrico dopo un muro. Entrambi avvengono alla fine, ma risolvono problemi diversi.
La rampa non ha partecipato ai test generici della forma. finishMovement() deve quindi registrare esplicitamente:
- l'oggetto in
_collisions; - l'asse
DOWNin_collisionAxes; - profondità zero in
_collisionDepths, perché il corpo è già allineato.
Questi dati servono anche a midair() nel percorso discreto, che cerca un contatto con un oggetto statico o cinematico sotto il corpo. Segue la coppia di notifiche logiche: la rampa riceve UP, il corpo DOWN, con begin = true.
Infine _slope torna nullptr: è il candidato temporaneo del passo, non l'archivio dei contatti. Azzerare quel puntatore non cancella _collisions. CollidableObject::detectDecollisions() viene eseguito dopo, confronta i contatti con quelli precedenti e notifica begin = false solo per quelli effettivamente terminati. Invertire l'ordine farebbe sembrare terminato un appoggio che sta per essere registrato di nuovo.
Platform lascia passare tutti i dinamici dal basso e dai lati, consentendo l'appoggio dall'alto. Il filtro usa movementStartCollider(), costruito con _movementStartPos: il broad phase CCD sposta temporaneamente il corpo in avanti e non deve alterare il significato della posizione di partenza.
Questo evita anche che un salto in cui soltanto la testa supera la piattaforma venga scambiato per un atterraggio. collidableWith viene consultato da entrambe le parti, mentre i test della piattaforma ammettono soltanto una risposta verso l'alto.
Gli oggetti cinematici possono disabilitare il CCD del passeggero durante il contatto. Per questo la strategia delle rampe è integrata nei due metodi comuni prepareMovement() e finishMovement(): non dipende esclusivamente dall'override di un solver CCD.
| Nome locale | Valore | Unità e scopo |
|---|---|---|
contactTolerance |
0.01 |
Unità di scena: mantenimento dell'appoggio e confronto con la quota pianificata. |
velTolerance |
0.01 |
Unità di scena/s: confronto fra velocità pianificata e risolta. |
climbTolerance |
0.0001 |
Unità di scena: riconoscimento di una salita bloccata. |
joinTolerance |
0.001 |
Unità di scena: coincidenza dei bordi nel raccordo alto. |
Le tolleranze geometriche non devono essere confuse con quelle sulle velocità, anche quando hanno lo stesso valore numerico.
Il modello assume rampe statiche, rettilinee e con dimensioni positive; i corpi rimangono AABB e non ruotano. Il supporto viene cercato scorrendo tutti gli oggetti della scena due volte per dinamico: le query spaziali del solver generico non accelerano automaticamente queste scansioni.
La ricerca sulle slope usa confronti fra posizione iniziale e candidata, non un calcolo generale del primo tempo d'impatto con tutti i segmenti. Un passo abbastanza lungo da superare completamente una rampa può non avere sovrapposizione alla fine. Inoltre il riallineamento verticale finale non esegue un nuovo sweep contro tutti gli ostacoli: configurazioni strette con muro, discesa e soffitto richiedono verifiche specifiche. I raccordi alti presuppongono la continuità descritta sopra e l'esclusione riguarda l'intero blocco.
Queste scelte mantengono leggibile un controller arcade. Non equivalgono a una soluzione generale per mesh arbitrarie, rampe mobili o simulazione di corpi rigidi.
I test opzionali coprono rampe in entrambi i versi, CCD e modalità discreta, raccordi, valli, salto e atterraggio, pareti e soffitti, trigger, piattaforme attraversabili, scivolamento e rilascio in salita. Il test di integrazione carica il livello e gli asset, muove il vero Mario e verifica la serializzazione dell'editor.
cmake -S CustomPlatformerSlopes -B ../build-CustomPlatformerSlopes -DSLOPES_BUILD_TESTS=ON
cmake --build ../build-CustomPlatformerSlopes
ctest --test-dir ../build-CustomPlatformerSlopes --output-on-failureIl refactoring del 23 ottobre 2026 conserva le regole del prototipo e rende esplicite le fasi sopra descritte. Entrambi i gruppi di test sono passati prima e dopo l'intervento.
- DynamicObject.cpp: pianificazione, conferma del supporto e raccordi.
- DynamicObject.h: significato dello stato temporaneo.
- Slope.cpp: geometria, fattori e scivolamento.
- CollidableObject.cpp: ordine del passo, solver e fine dei contatti.
- MovableObject.cpp: integrazione e interrogazioni dello stato fisico.
- Platform.cpp e KinematicObject.cpp: piattaforme e passeggeri.
- Test di regressione e guida di compilazione.
- Figure SVG e provenienza.
- Panoramica
- Diagramma delle classi
- Architettura e game loop
- Sequenza di un frame
- Input ed eventi
- Scene e oggetti
- Rendering e coordinate
- Sprite e testo
- UI e overlay
- Quadtree
- Servizi e asset
- Estendere il core
- Editor dei livelli
- LevelData e livelli JSON
- Shader