The open source repository for the action RPG game in development by Sig Productions titled 'Adventures in Lestoria'!
https://forums.lestoria.net
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
459 lines
15 KiB
459 lines
15 KiB
#pragma region License
|
|
/*
|
|
License (OLC-3)
|
|
~~~~~~~~~~~~~~~
|
|
|
|
Copyright 2018 - 2023 OneLoneCoder.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.
|
|
*/
|
|
#pragma endregion
|
|
#include "Monster.h"
|
|
#include "DamageNumber.h"
|
|
#include "Crawler.h"
|
|
#include "Bullet.h"
|
|
#include "BulletTypes.h"
|
|
#include "DEFINES.h"
|
|
#include "safemap.h"
|
|
#include "MonsterStrategyHelpers.h"
|
|
#include "util.h"
|
|
#include "MonsterAttribute.h"
|
|
#include "ItemDrop.h"
|
|
|
|
INCLUDE_ANIMATION_DATA
|
|
INCLUDE_MONSTER_DATA
|
|
INCLUDE_MONSTER_LIST
|
|
INCLUDE_DAMAGENUMBER_LIST
|
|
INCLUDE_game
|
|
INCLUDE_BULLET_LIST
|
|
INCLUDE_DATA
|
|
INCLUDE_STRATEGY_DATA
|
|
INCLUDE_GFX
|
|
|
|
safemap<std::string,int>STRATEGY_DATA;
|
|
safemap<int,std::string>STRATEGY_ID_DATA;
|
|
std::map<int,Renderable*>MonsterData::imgs;
|
|
|
|
Monster::Monster(vf2d pos,MonsterData data,bool upperLevel,bool bossMob):
|
|
pos(pos),hp(data.GetHealth()),maxhp(data.GetHealth()),atk(data.GetAttack()),moveSpd(data.GetMoveSpdMult()),size(data.GetSizeMult()),targetSize(data.GetSizeMult()),strategy(data.GetAIStrategy()),id(data.GetID()),upperLevel(upperLevel),isBoss(bossMob),facingDirection(DOWN){
|
|
bool firstAnimation=true;
|
|
for(std::string&anim:data.GetAnimations()){
|
|
animation.AddState(anim,ANIMATION_DATA[anim]);
|
|
if(firstAnimation){
|
|
animation.ChangeState(internal_animState,anim);
|
|
firstAnimation=false;
|
|
}
|
|
}
|
|
randomFrameOffset=(rand()%1000)/1000.f;
|
|
}
|
|
vf2d&Monster::GetPos(){
|
|
return pos;
|
|
}
|
|
int Monster::GetHealth(){
|
|
return hp;
|
|
}
|
|
int Monster::GetAttack(){
|
|
float mod_atk=float(atk);
|
|
for(Buff&b:GetBuffs(ATTACK_UP)){
|
|
mod_atk+=atk*b.intensity;
|
|
}
|
|
return int(mod_atk);
|
|
}
|
|
float Monster::GetMoveSpdMult(){
|
|
float mod_moveSpd=moveSpd;
|
|
for(Buff&b:GetBuffs(SLOWDOWN)){
|
|
mod_moveSpd-=moveSpd*b.intensity;
|
|
}
|
|
return mod_moveSpd;
|
|
}
|
|
float Monster::GetSizeMult(){
|
|
return size;
|
|
}
|
|
Animate2D::Frame Monster::GetFrame(){
|
|
return animation.GetFrame(internal_animState);
|
|
}
|
|
void Monster::UpdateAnimation(std::string state){
|
|
animation.ChangeState(internal_animState,state);
|
|
}
|
|
void Monster::PerformJumpAnimation(){
|
|
animation.ChangeState(internal_animState,MONSTER_DATA[id].GetJumpAnimation());
|
|
}
|
|
void Monster::PerformShootAnimation(){
|
|
animation.ChangeState(internal_animState,MONSTER_DATA[id].GetShootAnimation());
|
|
}
|
|
void Monster::PerformIdleAnimation(){
|
|
animation.ChangeState(internal_animState,MONSTER_DATA[id].GetIdleAnimation());
|
|
}
|
|
bool Monster::SetX(float x){
|
|
vf2d newPos={x,pos.y};
|
|
vi2d tilePos=vi2d(newPos/float(game->GetCurrentMap().tilewidth))*game->GetCurrentMap().tilewidth;
|
|
geom2d::rect<int>collisionRect=game->GetTileCollision(game->GetCurrentLevel(),newPos,upperLevel);
|
|
if(collisionRect.pos==vi2d{0,0}&&collisionRect.size==vi2d{1,1}){
|
|
pos.x=std::clamp(x,game->GetCurrentMap().tilewidth/2.f*GetSizeMult(),float(game->GetCurrentMap().width*game->GetCurrentMap().tilewidth-game->GetCurrentMap().tilewidth/2.f*GetSizeMult()));
|
|
Moved();
|
|
return true;
|
|
} else {
|
|
geom2d::rect<float>collision={collisionRect.pos,collisionRect.size};
|
|
collision.pos+=tilePos;
|
|
if(!geom2d::overlaps(geom2d::circle<float>(newPos,12*GetSizeMult()),collision)){
|
|
pos.x=std::clamp(x,game->GetCurrentMap().tilewidth/2.f*GetSizeMult(),float(game->GetCurrentMap().width*game->GetCurrentMap().tilewidth-game->GetCurrentMap().tilewidth/2.f*GetSizeMult()));
|
|
Moved();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
bool Monster::SetY(float y){
|
|
vf2d newPos={pos.x,y};
|
|
vi2d tilePos=vi2d(newPos/float(game->GetCurrentMap().tilewidth))*game->GetCurrentMap().tilewidth;
|
|
geom2d::rect<int>collisionRect=game->GetTileCollision(game->GetCurrentLevel(),newPos,upperLevel);
|
|
if(collisionRect.pos==vi2d{0,0}&&collisionRect.size==vi2d{1,1}){
|
|
pos.y=std::clamp(y,game->GetCurrentMap().tilewidth/2.f*GetSizeMult(),float(game->GetCurrentMap().height*game->GetCurrentMap().tilewidth-game->GetCurrentMap().tilewidth/2.f*GetSizeMult()));
|
|
Moved();
|
|
return true;
|
|
} else {
|
|
geom2d::rect<float>collision={collisionRect.pos,collisionRect.size};
|
|
collision.pos+=tilePos;
|
|
if(!geom2d::overlaps(geom2d::circle<float>(newPos,game->GetCurrentMap().tilewidth/2*GetSizeMult()),collision)){
|
|
pos.y=std::clamp(y,game->GetCurrentMap().tilewidth/2.f*GetSizeMult(),float(game->GetCurrentMap().height*game->GetCurrentMap().tilewidth-game->GetCurrentMap().tilewidth/2.f*GetSizeMult()));
|
|
Moved();
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
bool Monster::Update(float fElapsedTime){
|
|
lastHitTimer=std::max(0.f,lastHitTimer-fElapsedTime);
|
|
iframe_timer=std::max(0.f,iframe_timer-fElapsedTime);
|
|
if(size!=targetSize){
|
|
if(size>targetSize){
|
|
size=std::max(targetSize,size-Crawler::SIZE_CHANGE_SPEED*fElapsedTime);
|
|
}else{
|
|
size=std::min(targetSize,size+Crawler::SIZE_CHANGE_SPEED*fElapsedTime);
|
|
}
|
|
}
|
|
if(IsAlive()){
|
|
for(std::vector<Buff>::iterator it=buffList.begin();it!=buffList.end();++it){
|
|
Buff&b=*it;
|
|
b.duration-=fElapsedTime;
|
|
if(b.duration<=0){
|
|
it=buffList.erase(it);
|
|
if(it==buffList.end())break;
|
|
}
|
|
}
|
|
if(!HasIframes()){
|
|
for(Monster&m:MONSTER_LIST){
|
|
if(&m==this)continue;
|
|
if(!m.HasIframes()&&OnUpperLevel()==m.OnUpperLevel()&&abs(m.GetZ()-GetZ())<=1&&geom2d::overlaps(geom2d::circle(pos,12*size/2),geom2d::circle(m.GetPos(),12*m.GetSizeMult()/2))){
|
|
m.Collision(*this);
|
|
geom2d::line line(pos,m.GetPos());
|
|
float dist = line.length();
|
|
m.SetPos(line.rpoint(dist*1.1f));
|
|
if(m.IsAlive()){
|
|
vel=line.vector().norm()*-128;
|
|
}
|
|
}
|
|
}
|
|
if(!game->GetPlayer()->HasIframes()&&abs(game->GetPlayer()->GetZ()-GetZ())<=1&&game->GetPlayer()->OnUpperLevel()==OnUpperLevel()&&geom2d::overlaps(geom2d::circle(pos,12*size/2),geom2d::circle(game->GetPlayer()->GetPos(),12*game->GetPlayer()->GetSizeMult()/2))){
|
|
geom2d::line line(pos,game->GetPlayer()->GetPos());
|
|
float dist = line.length();
|
|
SetPos(line.rpoint(-0.1f));
|
|
vel=line.vector().norm()*-128;
|
|
}
|
|
}
|
|
if(GetState()==State::NORMAL){
|
|
if(game->GetPlayer()->GetX()>pos.x){
|
|
facingDirection=RIGHT;
|
|
} else {
|
|
facingDirection=LEFT;
|
|
}
|
|
}
|
|
Monster::STRATEGY::RUN_STRATEGY(*this,fElapsedTime);
|
|
if(vel.x>0){
|
|
vel.x=std::max(0.f,vel.x-friction*fElapsedTime);
|
|
} else {
|
|
vel.x=std::min(0.f,vel.x+friction*fElapsedTime);
|
|
}
|
|
if(vel.y>0){
|
|
vel.y=std::max(0.f,vel.y-friction*fElapsedTime);
|
|
} else {
|
|
vel.y=std::min(0.f,vel.y+friction*fElapsedTime);
|
|
}
|
|
if(vel!=vf2d{0,0}){
|
|
SetX(pos.x+vel.x*fElapsedTime);
|
|
SetY(pos.y+vel.y*fElapsedTime);
|
|
}
|
|
}
|
|
if(!IsAlive()){
|
|
deathTimer+=fElapsedTime;
|
|
if(deathTimer>3){
|
|
return false;
|
|
}
|
|
}
|
|
animation.UpdateState(internal_animState,randomFrameOffset+fElapsedTime);
|
|
randomFrameOffset=0;
|
|
return true;
|
|
}
|
|
Key Monster::GetFacingDirection(){
|
|
return facingDirection;
|
|
}
|
|
void Monster::Draw(){
|
|
if(GetZ()>0){
|
|
vf2d shadowScale=vf2d{8*GetSizeMult()/3.f,1}/std::max(1.f,GetZ()/24);
|
|
game->view.DrawDecal(GetPos()-vf2d{3,3}*shadowScale/2+vf2d{0,6*GetSizeMult()},GFX["circle.png"].Decal(),shadowScale,BLACK);
|
|
}
|
|
game->view.DrawPartialRotatedDecal(GetPos()-vf2d{0,GetZ()},GetFrame().GetSourceImage()->Decal(),0,GetFrame().GetSourceRect().size/2,GetFrame().GetSourceRect().pos,GetFrame().GetSourceRect().size,vf2d(GetSizeMult()*(GetFacingDirection()==RIGHT?-1:1),GetSizeMult()),GetBuffs(BuffType::SLOWDOWN).size()>0?Pixel{uint8_t(255*abs(sin(1.4*GetBuffs(BuffType::SLOWDOWN)[0].duration))),uint8_t(255*abs(sin(1.4*GetBuffs(BuffType::SLOWDOWN)[0].duration))),uint8_t(128+127*abs(sin(1.4*GetBuffs(BuffType::SLOWDOWN)[0].duration)))}:WHITE);
|
|
}
|
|
void Monster::DrawReflection(float drawRatioX,float multiplierX){
|
|
game->SetDecalMode(DecalMode::ADDITIVE);
|
|
game->view.DrawPartialRotatedDecal(GetPos()+vf2d{drawRatioX*GetFrame().GetSourceRect().size.x,GetZ()+(GetFrame().GetSourceRect().size.y-16)*GetSizeMult()},GetFrame().GetSourceImage()->Decal(),0,GetFrame().GetSourceRect().size/2,GetFrame().GetSourceRect().pos,GetFrame().GetSourceRect().size,vf2d(GetSizeMult()*(GetFacingDirection()==RIGHT?-1:1)*multiplierX,GetSizeMult()*-1),GetBuffs(BuffType::SLOWDOWN).size()>0?Pixel{uint8_t(255*abs(sin(1.4*GetBuffs(BuffType::SLOWDOWN)[0].duration))),uint8_t(255*abs(sin(1.4*GetBuffs(BuffType::SLOWDOWN)[0].duration))),uint8_t(128+127*abs(sin(1.4*GetBuffs(BuffType::SLOWDOWN)[0].duration)))}:WHITE);
|
|
game->SetDecalMode(DecalMode::NORMAL);
|
|
}
|
|
void Monster::Collision(Player*p){
|
|
if(MONSTER_DATA[id].GetCollisionDmg()>0&&!hasHitPlayer){
|
|
if(p->Hurt(MONSTER_DATA[id].GetCollisionDmg(),OnUpperLevel(),GetZ())){
|
|
hasHitPlayer=true;
|
|
}
|
|
}
|
|
Collision();
|
|
}
|
|
void Monster::Collision(Monster&m){
|
|
Collision();
|
|
}
|
|
void Monster::Collision(){
|
|
if(strategy==0&&GetState()==State::MOVE_TOWARDS&&util::random(float(Monster::STRATEGY::_GetInt(*this,"BumpStopChance",strategy)))<1){//The run towards strategy causes state to return to normal upon a collision.
|
|
SetState(State::NORMAL);
|
|
}
|
|
}
|
|
void Monster::SetVelocity(vf2d vel){
|
|
this->vel=vel;
|
|
}
|
|
bool Monster::SetPos(vf2d pos){
|
|
bool resultX=SetX(pos.x);
|
|
bool resultY=SetY(pos.y);
|
|
if(resultY&&!resultX){
|
|
resultX=SetX(pos.x);
|
|
}
|
|
return resultX||resultY;
|
|
}
|
|
void Monster::Moved(){
|
|
std::map<std::string,std::vector<ZoneData>>&zoneData=game->GetZoneData(game->GetCurrentLevel());
|
|
for(ZoneData&upperLevelZone:zoneData["UpperZone"]){
|
|
if(geom2d::overlaps(upperLevelZone.zone,pos)){
|
|
upperLevel=true;
|
|
}
|
|
}
|
|
for(ZoneData&lowerLevelZone:zoneData["LowerZone"]){
|
|
if(geom2d::overlaps(lowerLevelZone.zone,pos)){
|
|
upperLevel=false;
|
|
}
|
|
}
|
|
}
|
|
std::string Monster::GetDeathAnimationName(){
|
|
return MONSTER_DATA[id].GetDeathAnimation();
|
|
}
|
|
bool Monster::Hurt(int damage,bool onUpperLevel,float z){
|
|
if(!IsAlive()||onUpperLevel!=OnUpperLevel()||HasIframes()||abs(GetZ()-z)>1) return false;
|
|
if(game->InBossEncounter()){
|
|
game->StartBossEncounter();
|
|
}
|
|
game->GetPlayer()->ResetLastCombatTime();
|
|
float mod_dmg=float(damage);
|
|
for(Buff&b:GetBuffs(BuffType::DAMAGE_REDUCTION)){
|
|
mod_dmg-=damage*b.intensity;
|
|
}
|
|
hp=std::max(0,hp-int(mod_dmg));
|
|
if(lastHitTimer>0){
|
|
damageNumberPtr.get()->damage+=int(mod_dmg);
|
|
damageNumberPtr.get()->pauseTime=0.4f;
|
|
} else {
|
|
damageNumberPtr=std::make_shared<DamageNumber>(pos,int(mod_dmg));
|
|
DAMAGENUMBER_LIST.push_back(damageNumberPtr);
|
|
}
|
|
lastHitTimer=0.05f;
|
|
if(!IsAlive()){
|
|
OnDeath();
|
|
}else{
|
|
hp=std::max(1,hp); //Make sure it stays alive if it's supposed to be alive...
|
|
}
|
|
if(game->InBossEncounter()){
|
|
game->BossDamageDealt(int(mod_dmg));
|
|
}
|
|
GetInt(Attribute::HITS_UNTIL_DEATH)=std::max(0,GetInt(Attribute::HITS_UNTIL_DEATH)-1);
|
|
iframe_timer=GetFloat(Attribute::IFRAME_TIME_UPON_HIT);
|
|
return true;
|
|
}
|
|
|
|
bool Monster::IsAlive(){
|
|
return hp>0||!diesNormally;
|
|
}
|
|
vf2d&Monster::GetTargetPos(){
|
|
return target;
|
|
}
|
|
|
|
MonsterSpawner::MonsterSpawner(){}
|
|
MonsterSpawner::MonsterSpawner(vf2d pos,vf2d range,std::vector<std::pair<int,vf2d>>monsters,bool upperLevel,std::string bossNameDisplay)
|
|
:pos(pos),range(range),monsters(monsters),upperLevel(upperLevel),bossNameDisplay(bossNameDisplay){
|
|
}
|
|
bool MonsterSpawner::SpawnTriggered(){
|
|
return triggered;
|
|
}
|
|
vf2d MonsterSpawner::GetRange(){
|
|
return range;
|
|
}
|
|
vf2d MonsterSpawner::GetPos(){
|
|
return pos;
|
|
}
|
|
|
|
void MonsterSpawner::SetTriggered(bool trigger,bool spawnMonsters){
|
|
triggered=trigger;
|
|
if(spawnMonsters){
|
|
for(std::pair<int,vf2d>&monsterInfo:monsters){
|
|
game->SpawnMonster(pos+monsterInfo.second,&MONSTER_DATA[monsterInfo.first],DoesUpperLevelSpawning(),bossNameDisplay!="");
|
|
}
|
|
if(bossNameDisplay!=""){
|
|
game->SetBossNameDisplay(bossNameDisplay);
|
|
}
|
|
}
|
|
}
|
|
|
|
bool MonsterSpawner::DoesUpperLevelSpawning(){
|
|
return upperLevel;
|
|
}
|
|
|
|
bool Monster::OnUpperLevel(){
|
|
return upperLevel;
|
|
}
|
|
|
|
void Monster::AddBuff(BuffType type,float duration,float intensity){
|
|
buffList.push_back(Buff{type,duration,intensity});
|
|
}
|
|
|
|
void Monster::StartPathfinding(float pathingTime){
|
|
SetState(State::PATH_AROUND);
|
|
path=game->pathfinder.Solve_AStar(pos,target,12,OnUpperLevel());
|
|
if(path.size()>0){
|
|
pathIndex=0;
|
|
//We gives this mob 5 seconds to figure out a path to the target.
|
|
targetAcquireTimer=pathingTime;
|
|
}
|
|
}
|
|
|
|
void Monster::PathAroundBehavior(float fElapsedTime){
|
|
if(path.size()>0){
|
|
//Move towards the new path.
|
|
geom2d::line moveTowardsLine=geom2d::line(pos,path[pathIndex]*float(game->GetCurrentMap().tilewidth));
|
|
if(moveTowardsLine.length()>2){
|
|
SetPos(pos+moveTowardsLine.vector().norm()*100*fElapsedTime*GetMoveSpdMult());
|
|
if(moveTowardsLine.vector().x>0){
|
|
facingDirection=RIGHT;
|
|
} else {
|
|
facingDirection=LEFT;
|
|
}
|
|
}else{
|
|
if(size_t(pathIndex+1)>=path.size()){
|
|
//We have reached the end of the path!
|
|
targetAcquireTimer=0;
|
|
}else{
|
|
pathIndex++;
|
|
}
|
|
}
|
|
} else {
|
|
//We actually can't do anything so just quit.
|
|
targetAcquireTimer=0;
|
|
}
|
|
}
|
|
|
|
std::vector<Buff>Monster::GetBuffs(BuffType buff){
|
|
std::vector<Buff>filteredBuffs;
|
|
std::copy_if(buffList.begin(),buffList.end(),std::back_inserter(filteredBuffs),[buff](Buff&b){return b.type==buff;});
|
|
return filteredBuffs;
|
|
}
|
|
|
|
State::State Monster::GetState(){
|
|
return state;
|
|
}
|
|
|
|
void Monster::SetState(State::State newState){
|
|
state=newState;
|
|
}
|
|
|
|
void Monster::InitializeStrategies(){
|
|
int readCounter=0;
|
|
while(DATA["MonsterStrategy"].HasProperty(std::to_string(readCounter))){
|
|
std::string strategyName=DATA["MonsterStrategy"][std::to_string(readCounter)]["Name"].GetString();
|
|
STRATEGY_DATA[strategyName]=readCounter;
|
|
STRATEGY_ID_DATA[readCounter]=strategyName;
|
|
readCounter++;
|
|
}
|
|
STRATEGY_DATA.SetInitialized();
|
|
STRATEGY_ID_DATA.SetInitialized();
|
|
}
|
|
|
|
bool Monster::HasIframes(){
|
|
return iframe_timer>0;
|
|
}
|
|
|
|
float Monster::GetZ(){
|
|
return z;
|
|
}
|
|
|
|
std::string Monster::GetStrategy(){
|
|
return STRATEGY_ID_DATA[strategy];
|
|
}
|
|
|
|
void Monster::SetSize(float newSize,bool immediate){
|
|
if(immediate){
|
|
size=targetSize=newSize;
|
|
}else{
|
|
targetSize=newSize;
|
|
}
|
|
}
|
|
|
|
void Monster::SetZ(float z){
|
|
this->z=z;
|
|
}
|
|
|
|
void Monster::SetStrategyDrawFunction(std::function<void(Crawler*)>func){
|
|
strategyDraw=func;
|
|
}
|
|
|
|
void Monster::OnDeath(){
|
|
animation.ChangeState(internal_animState,GetDeathAnimationName());
|
|
if(isBoss){
|
|
game->ReduceBossEncounterMobCount();
|
|
}
|
|
|
|
for(MonsterDropData data:MONSTER_DATA.at(id).GetDropData()){
|
|
if(util::random(100)<=data.dropChance){
|
|
//This isn't necessarily fair odds for each quantity dropped.
|
|
int dropQuantity=int(data.minQty+std::round(util::random(float(data.maxQty-data.minQty))));
|
|
for(int i=0;i<dropQuantity;i++){
|
|
ItemDrop::SpawnItem(data.item,GetPos(),OnUpperLevel());
|
|
}
|
|
}
|
|
}
|
|
} |