Updated hamster images with profile icons. Added live placement tracking.

sigonasr2 3 months ago
parent 92f2b4dd85
commit 8551c9bacd
  1. BIN
      assets/hamster.xcf
  2. BIN
      assets/hamster1.png
  3. BIN
      assets/hamster2.png
  4. BIN
      assets/hamster3.png
  5. BIN
      assets/hamster4.png
  6. BIN
      assets/hamster5.png
  7. BIN
      assets/hamster6.png
  8. BIN
      assets/hamster7.png
  9. BIN
      assets/hamster8.png
  10. BIN
      assets/raceprogress.png
  11. 5
      src/Checkpoint.cpp
  12. 15
      src/Hamster.cpp
  13. 7
      src/Hamster.h
  14. 15
      src/HamsterGame.cpp
  15. 3
      src/HamsterGame.h
  16. 114
      src/HamsterLeaderboard.cpp
  17. 13
      src/HamsterLeaderboard.h
  18. 5
      src/TODO.txt

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

@ -86,7 +86,10 @@ void Checkpoint::DrawCheckpoints(TransformedView&tv){
if(screenDistance>226){ if(screenDistance>226){
const vf2d dirVec{playerToCheckpointLine.vector().norm()}; const vf2d dirVec{playerToCheckpointLine.vector().norm()};
const float dir{dirVec.polar().y}; const float dir{dirVec.polar().y};
std::optional<vf2d>projCircle{geom2d::project(geom2d::circle<float>({},16),HamsterGame::SCREEN_FRAME,geom2d::ray<float>(HamsterGame::SCREEN_FRAME.middle(),dirVec))}; geom2d::rect<float>screenBounds{HamsterGame::SCREEN_FRAME};
screenBounds.pos.x+=16.f;
screenBounds.size.x-=16.f;
std::optional<vf2d>projCircle{geom2d::project(geom2d::circle<float>({},16),screenBounds,geom2d::ray<float>(HamsterGame::SCREEN_FRAME.middle(),dirVec))};
if(projCircle.has_value()){ if(projCircle.has_value()){
Pixel arrowCol{PixelLerp(GREEN,BLACK,std::clamp((screenDistance-226)/1000.f,0.f,1.f))}; Pixel arrowCol{PixelLerp(GREEN,BLACK,std::clamp((screenDistance-226)/1000.f,0.f,1.f))};
uint8_t iconAlpha{uint8_t(util::lerp(255.f,0.f,std::clamp((screenDistance-226)/1000.f,0.f,1.f)))}; uint8_t iconAlpha{uint8_t(util::lerp(255.f,0.f,std::clamp((screenDistance-226)/1000.f,0.f,1.f)))};

@ -422,8 +422,8 @@ void Hamster::DrawOverlay(){
if(GetPlayer().HasPowerup(Powerup::JET))HamsterGame::Game().DrawDecal(jetDisplayOffset+vf2d{22.f,137.f},HamsterGame::GetGFX("fuelbar_outline.png").Decal(),{1.f,1.f},GetPlayer().jetFuel<=0.2f?(fmod(GetPlayer().readyFlashTimer,1.f)<=0.5f?RED:BLACK):BLACK); if(GetPlayer().HasPowerup(Powerup::JET))HamsterGame::Game().DrawDecal(jetDisplayOffset+vf2d{22.f,137.f},HamsterGame::GetGFX("fuelbar_outline.png").Decal(),{1.f,1.f},GetPlayer().jetFuel<=0.2f?(fmod(GetPlayer().readyFlashTimer,1.f)<=0.5f?RED:BLACK):BLACK);
if(GetPlayer().HasPowerup(Powerup::WHEEL)){ if(GetPlayer().HasPowerup(Powerup::WHEEL)){
for(int i:std::ranges::iota_view(0,3)){ for(int i:std::ranges::iota_view(0,3)){
if(fmod(HamsterGame::Game().GetRuntime(),2.f)<1.f&&GetPlayer().boostCounter>i)HamsterGame::Game().DrawDecal(HamsterGame::SCREEN_FRAME.pos+vf2d{i*16.f+4.f,HamsterGame::SCREEN_FRAME.size.y-20.f},HamsterGame::GetGFX("boost_outline.png").Decal(),{0.125f,0.125f},GetPlayer().boostCounter>i?WHITE:BLACK); if(fmod(HamsterGame::Game().GetRuntime(),2.f)<1.f&&GetPlayer().boostCounter>i)HamsterGame::Game().DrawDecal(HamsterGame::SCREEN_FRAME.pos+vf2d{i*16.f+4.f,HamsterGame::SCREEN_FRAME.size.y-18.f},HamsterGame::GetGFX("boost_outline.png").Decal(),{0.125f,0.125f},GetPlayer().boostCounter>i?WHITE:BLACK);
else HamsterGame::Game().DrawDecal(HamsterGame::SCREEN_FRAME.pos+vf2d{i*16.f+4.f,HamsterGame::SCREEN_FRAME.size.y-20.f},HamsterGame::GetGFX("boost.png").Decal(),{0.125f,0.125f},GetPlayer().boostCounter>i?WHITE:BLACK); else HamsterGame::Game().DrawDecal(HamsterGame::SCREEN_FRAME.pos+vf2d{i*16.f+4.f,HamsterGame::SCREEN_FRAME.size.y-18.f},HamsterGame::GetGFX("boost.png").Decal(),{0.125f,0.125f},GetPlayer().boostCounter>i?WHITE:BLACK);
} }
for(int y:std::ranges::iota_view(-1,2)){ for(int y:std::ranges::iota_view(-1,2)){
for(int x:std::ranges::iota_view(-1,2)){ for(int x:std::ranges::iota_view(-1,2)){
@ -561,6 +561,7 @@ void Hamster::HandleCollision(){
if(IsPlayerControlled)HamsterAI::OnCheckpointCollected(this->pos); if(IsPlayerControlled)HamsterAI::OnCheckpointCollected(this->pos);
if(IsPlayerControlled)checkpoint.OnCheckpointCollect(); if(IsPlayerControlled)checkpoint.OnCheckpointCollect();
if(CollectedAllCheckpoints()){finishedRaceTime=HamsterGame::Game().GetRaceTime();} if(CollectedAllCheckpoints()){finishedRaceTime=HamsterGame::Game().GetRaceTime();}
lastObtainedCheckpointPos=checkpoint.GetPos();
} }
} }
if(GetState()==NORMAL){ if(GetState()==NORMAL){
@ -842,7 +843,7 @@ const bool Hamster::CollectedAllCheckpoints()const{
const bool Hamster::HasCollectedCheckpoint(const Checkpoint&cp)const{ const bool Hamster::HasCollectedCheckpoint(const Checkpoint&cp)const{
return checkpointsCollected.contains(cp.GetPos()); return checkpointsCollected.contains(cp.GetPos());
} }
const std::vector<Hamster>&Hamster::GetHamsters(){ std::vector<Hamster>&Hamster::GetHamsters(){
return HAMSTER_LIST; return HAMSTER_LIST;
} }
const Hamster::HamsterState&Hamster::GetState()const{ const Hamster::HamsterState&Hamster::GetState()const{
@ -1029,4 +1030,12 @@ const vf2d Hamster::GetAINodePositionVariance()const{
}break; }break;
} }
return finalOffset; return finalOffset;
}
const size_t Hamster::GetCheckpointsCollectedCount()const{
return checkpointsCollected.size();
}
const std::optional<vf2d>Hamster::GetLastCollectedCheckpoint()const{
return lastObtainedCheckpointPos;
} }

@ -104,7 +104,6 @@ class Hamster{
std::string img; std::string img;
Animate2D::Animation<AnimationState::AnimationState>animations; Animate2D::Animation<AnimationState::AnimationState>animations;
Animate2D::AnimationState internalAnimState; Animate2D::AnimationState internalAnimState;
PlayerControlled IsPlayerControlled;
static std::optional<Hamster*>playerHamster; static std::optional<Hamster*>playerHamster;
HamsterState state{NORMAL}; HamsterState state{NORMAL};
std::unordered_set<Powerup::PowerupType>powerups; std::unordered_set<Powerup::PowerupType>powerups;
@ -132,6 +131,7 @@ class Hamster{
std::string colorFilename; std::string colorFilename;
int points{}; int points{};
std::optional<int>finishedRaceTime; std::optional<int>finishedRaceTime;
std::optional<vf2d>lastObtainedCheckpointPos;
HamsterAI::AIType aiLevel{HamsterAI::AIType::NORMAL}; HamsterAI::AIType aiLevel{HamsterAI::AIType::NORMAL};
public: public:
Hamster(const vf2d spawnPos,const std::string&img,const PlayerControlled IsPlayerControlled=NPC); Hamster(const vf2d spawnPos,const std::string&img,const PlayerControlled IsPlayerControlled=NPC);
@ -178,7 +178,7 @@ public:
void SetState(const HamsterState state); void SetState(const HamsterState state);
const bool CollectedAllCheckpoints()const; const bool CollectedAllCheckpoints()const;
const bool HasCollectedCheckpoint(const Checkpoint&cp)const; const bool HasCollectedCheckpoint(const Checkpoint&cp)const;
static const std::vector<Hamster>&GetHamsters(); static std::vector<Hamster>&GetHamsters();
const HamsterState&GetState()const; const HamsterState&GetState()const;
const bool BurnedOrDrowned()const; const bool BurnedOrDrowned()const;
const bool CanMove()const; const bool CanMove()const;
@ -188,4 +188,7 @@ public:
const float GetAINodeDistanceVariance()const; const float GetAINodeDistanceVariance()const;
const vf2d GetAINodePositionVariance()const; const vf2d GetAINodePositionVariance()const;
const bool IsBurrowed()const; const bool IsBurrowed()const;
const size_t GetCheckpointsCollectedCount()const;
const std::optional<vf2d>GetLastCollectedCheckpoint()const;
PlayerControlled IsPlayerControlled;
}; };

@ -101,6 +101,7 @@ void HamsterGame::LoadGraphics(){
_LoadImage("background3.png"); _LoadImage("background3.png");
_LoadImage("background4.png"); _LoadImage("background4.png");
_LoadImage("background5.png"); _LoadImage("background5.png");
_LoadImage("raceprogress.png");
} }
void HamsterGame::LoadAnimations(){ void HamsterGame::LoadAnimations(){
@ -203,7 +204,13 @@ void HamsterGame::LoadLevel(const std::string&mapName){
} }
void HamsterGame::UpdateGame(const float fElapsedTime){ void HamsterGame::UpdateGame(const float fElapsedTime){
countdownTimer=std::max(0.f,countdownTimer-fElapsedTime); if(countdownTimer>0.f){
countdownTimer-=fElapsedTime;
if(countdownTimer<=0.f){
countdownTimer=0.f;
leaderboard.OnRaceStart();
}
}
vEye.z+=(Hamster::GetPlayer().GetZ()+zoom-vEye.z)*fLazyFollowRate*fElapsedTime; vEye.z+=(Hamster::GetPlayer().GetZ()+zoom-vEye.z)*fLazyFollowRate*fElapsedTime;
speedometerDisplayAmt+=(Hamster::GetPlayer().GetSpeed()-speedometerDisplayAmt)*fLazyFollowRate*fElapsedTime; speedometerDisplayAmt+=(Hamster::GetPlayer().GetSpeed()-speedometerDisplayAmt)*fLazyFollowRate*fElapsedTime;
@ -222,6 +229,7 @@ void HamsterGame::UpdateGame(const float fElapsedTime){
Powerup::UpdatePowerups(fElapsedTime); Powerup::UpdatePowerups(fElapsedTime);
Checkpoint::UpdateCheckpoints(fElapsedTime); Checkpoint::UpdateCheckpoints(fElapsedTime);
FloatingText::UpdateFloatingText(fElapsedTime); FloatingText::UpdateFloatingText(fElapsedTime);
leaderboard.Update();
border.Update(fElapsedTime); border.Update(fElapsedTime);
} }
@ -310,6 +318,7 @@ void HamsterGame::DrawGame(){
DrawStringDecal(SCREEN_FRAME.pos+SCREEN_FRAME.size-speedometerStrSize-vf2d{4.f,4.f},speedometerStr,speedometerCol); DrawStringDecal(SCREEN_FRAME.pos+SCREEN_FRAME.size-speedometerStrSize-vf2d{4.f,4.f},speedometerStr,speedometerCol);
DrawDecal({2.f,4.f},GetGFX("radar.png").Decal()); DrawDecal({2.f,4.f},GetGFX("radar.png").Decal());
DrawRadar(); DrawRadar();
leaderboard.Draw(*this);
DrawStringDecal({0,8.f},std::to_string(GetFPS())); DrawStringDecal({0,8.f},std::to_string(GetFPS()));
if(countdownTimer>0.f){ if(countdownTimer>0.f){
Pixel timerColor{fmod(countdownTimer,1.f)<0.5f?GREEN:WHITE}; Pixel timerColor{fmod(countdownTimer,1.f)<0.5f?GREEN:WHITE};
@ -677,6 +686,10 @@ const bool HamsterGame::RaceCountdownCompleted(){
return countdownTimer==0.f; return countdownTimer==0.f;
} }
const geom2d::rect<int>HamsterGame::GetMapSpawnRect()const{
return currentMap.value().GetData().GetSpawnZone();
}
int main() int main()
{ {
HamsterGame game("Project Hamster"); HamsterGame game("Project Hamster");

@ -52,6 +52,7 @@ All rights reserved.
#include "olcPGEX_MiniAudio.h" #include "olcPGEX_MiniAudio.h"
#include "HamsterNet.h" #include "HamsterNet.h"
#include "olcPGEX_SplashScreen.h" #include "olcPGEX_SplashScreen.h"
#include "HamsterLeaderboard.h"
struct Letter{ struct Letter{
vf2d pos; vf2d pos;
@ -103,6 +104,7 @@ public:
static void LoadPBs(); static void LoadPBs();
const int GetRaceTime(); const int GetRaceTime();
const bool RaceCountdownCompleted(); const bool RaceCountdownCompleted();
const geom2d::rect<int>GetMapSpawnRect()const;
private: private:
void UpdateGame(const float fElapsedTime); void UpdateGame(const float fElapsedTime);
void DrawGame(); void DrawGame();
@ -177,4 +179,5 @@ private:
"Red", "Red",
"Blue", "Blue",
}; };
HamsterLeaderboard leaderboard;
}; };

@ -0,0 +1,114 @@
#pragma region License
/*
License (OLC-3)
~~~~~~~~~~~~~~~
Copyright 2024 Joshua Sigona <sigonasr2@gmail.com>
Redistribution and use in source and binary forms, with or without modification,
are permitted provided that the following conditions are met:
1. Redistributions or derivations of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions or derivative works in binary form must reproduce the above
copyright notice. This list of conditions and the following disclaimer must be
reproduced in the documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its contributors may
be used to endorse or promote products derived from this software without specific
prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT
SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.
Portions of this software are copyright © 2024 The FreeType
Project (www.freetype.org). Please see LICENSE_FT.txt for more information.
All rights reserved.
*/
#pragma endregion
#include "HamsterLeaderboard.h"
#include "Checkpoint.h"
#include "Hamster.h"
#include <ranges>
void HamsterLeaderboard::OnRaceStart(){
hamsterRanking.clear();
for(Hamster&hamster:Hamster::GetHamsters())hamsterRanking.emplace_back(hamster);
}
HamsterLeaderboard::HamsterRanking::HamsterRanking(Hamster&hamsterRef)
:hamster(hamsterRef){}
void HamsterLeaderboard::Update(){
for(HamsterRanking&hamsterRank:hamsterRanking){
Hamster&hamster{hamsterRank.hamster.get()};
float finalRanking{float(hamster.GetCheckpointsCollectedCount())};
std::optional<std::reference_wrapper<Checkpoint>>closestCheckpoint;
for(Checkpoint&cp:Checkpoint::GetCheckpoints()){
float distToHamster{geom2d::line<float>(hamster.GetPos(),cp.GetPos()).length()};
if(hamster.HasCollectedCheckpoint(cp))continue;
if(!closestCheckpoint.has_value())closestCheckpoint=cp;
else if(distToHamster<geom2d::line<float>(hamster.GetPos(),closestCheckpoint.value().get().GetPos()).length())closestCheckpoint=cp;
}
if(closestCheckpoint.has_value()){
vf2d lastCollectedPos{};
if(hamster.GetLastCollectedCheckpoint().has_value())lastCollectedPos=hamster.GetLastCollectedCheckpoint().value();
else lastCollectedPos=HamsterGame::Game().GetMapSpawnRect().middle();
vf2d closestCheckpointPos{closestCheckpoint.value().get().GetPos()};
float totalDist{geom2d::line<float>(lastCollectedPos,closestCheckpointPos).length()};
float distFromHamsterToClosestCheckpoint{geom2d::line<float>(hamster.GetPos(),closestCheckpointPos).length()};
float additionalRanking{std::clamp((totalDist-distFromHamsterToClosestCheckpoint)/totalDist,0.0001f,0.9999f)};
finalRanking+=additionalRanking;
}
hamsterRank.ranking=std::min(finalRanking,float(Checkpoint::GetCheckpoints().size()));
}
std::sort(hamsterRanking.begin(),hamsterRanking.end(),[](const HamsterRanking&rank1,const HamsterRanking&rank2){return rank1.ranking<rank2.ranking;});
}
void HamsterLeaderboard::Draw(HamsterGame&game){
game.DrawRotatedDecal(HamsterGame::SCREEN_FRAME.pos+vf2d{8.f,HamsterGame::SCREEN_FRAME.size.y/2.f},game.GetGFX("raceprogress.png").Decal(),0.f,game.GetGFX("raceprogress.png").Sprite()->Size()/2,{1.f,1.f},WHITE);
vf2d progressBarBottomPos{HamsterGame::SCREEN_FRAME.pos+vf2d{8.f,HamsterGame::SCREEN_FRAME.size.y/2.f+game.GetGFX("raceprogress.png").Sprite()->height/2.f}};
for(int i:std::ranges::iota_view(0U,Checkpoint::GetCheckpoints().size()-1)){
game.FillRectDecal(progressBarBottomPos+vf2d{-5.f,-float((game.GetGFX("raceprogress.png").Sprite()->height/(Checkpoint::GetCheckpoints().size()))*(i+1))},{10.f,1.f},WHITE);
}
int playerPlacement{};
std::optional<std::reference_wrapper<HamsterRanking>>playerHamsterRanking{};
for(int placement{};HamsterRanking&ranking:hamsterRanking){
if(ranking.hamster.get().IsPlayerControlled){
playerHamsterRanking=ranking;
playerPlacement=hamsterRanking.size()-placement;
game.DrawPartialRotatedDecal(progressBarBottomPos+vf2d{0.f,-(float(ranking.ranking)/Checkpoint::GetCheckpoints().size())*game.GetGFX("raceprogress.png").Sprite()->height},ranking.hamster.get().GetCurrentAnimation().GetSourceImage()->Decal(),0.f,{8.f,6.f},{64.f,64.f},{16.f,12.f});
}else game.DrawPartialRotatedDecal(progressBarBottomPos+vf2d{0.f,-(float(ranking.ranking)/Checkpoint::GetCheckpoints().size())*game.GetGFX("raceprogress.png").Sprite()->height},ranking.hamster.get().GetCurrentAnimation().GetSourceImage()->Decal(),0.f,{8.f,6.f},{64.f,64.f},{16.f,12.f});
placement++;
}
if(playerHamsterRanking.has_value()){
std::string addonStr{"th"};
if(playerPlacement==1)addonStr="st";
else if(playerPlacement==2)addonStr="nd";
std::string placementStr{std::format("{}{}",playerPlacement,addonStr)};
vi2d placementStrSize{game.GetTextSizeProp(placementStr)};
Pixel blinkCol{DARK_RED};
if(playerPlacement==1)blinkCol=CYAN;
else if(playerPlacement<=3)blinkCol=DARK_GREEN;
for(int y:std::ranges::iota_view(-1,2)){
for(int x:std::ranges::iota_view(-1,2)){
game.DrawRotatedStringPropDecal(progressBarBottomPos+vf2d{-4.f,8.f}+vi2d{x,y},placementStr,0.f,{},BLACK,{3.f,3.f});
}
}
game.DrawRotatedStringPropDecal(progressBarBottomPos+vf2d{-4.f,8.f},placementStr,0.f,{},blinkCol,{3.f,3.f});
}
}
const float HamsterLeaderboard::HamsterRanking::GetRanking()const{
return ranking;
}

@ -36,15 +36,22 @@ All rights reserved.
*/ */
#pragma endregion #pragma endregion
#pragma once #pragma once
#include "Hamster.h" #include <functional>
class HamsterGame;
class Hamster;
class HamsterLeaderboard{ class HamsterLeaderboard{
class HamsterRanking{ class HamsterRanking{
friend class HamsterLeaderboard;
std::reference_wrapper<Hamster>hamster; std::reference_wrapper<Hamster>hamster;
float ranking; //The higher the ranking, the higher the hamster is placed. float ranking{}; //The higher the ranking, the higher the hamster is placed.
public:
HamsterRanking(Hamster&hamsterRef);
const float GetRanking()const;
}; };
std::vector<HamsterRanking>hamsterRanking; std::vector<HamsterRanking>hamsterRanking;
public: public:
void OnRaceStart(); void OnRaceStart();
void OnRaceFinished(); void Update();
void Draw(HamsterGame&game);
}; };

@ -155,9 +155,9 @@ Stage 12: Boss Battle #5 V2 - nene
Live Placement Tracking Live Placement Tracking
Pulsating Animation based on terrain walking across Pulsating Animation based on terrain walking across (30 min)
Sound Effects Sound Effects (1 hour)
Footstep sounds(Grass, Sand, Rock, Shore (Shared with Lava and Swamp), Ocean) Footstep sounds(Grass, Sand, Rock, Shore (Shared with Lava and Swamp), Ocean)
Hamster Wheel sounds (Rolling) Hamster Wheel sounds (Rolling)
@ -184,6 +184,7 @@ Menu Navigations (2 hours)
Grand Prix Management (1 hour) Grand Prix Management (1 hour)
Unlocks (1 hour) Unlocks (1 hour)
Leaderboard Management (1 hour) Leaderboard Management (1 hour)
Tutorial Stuff (3 hours)
Menus Menus
=========== ===========

Loading…
Cancel
Save