- Player-to-player marketplace with listings, buying and selling - Mission system with progress tracking and money rewards - Character appearance customization (skin, hair, face, beard, accessory) - Multiplayer presence feed showing nearby citizens in near-real-time - Admin world dashboard with live platform metrics - Bus and train transportation with temporary ride state - Mobile joystick controls and minimap HUD - Education bonus applied to work salary
303 lines
8.3 KiB
PHP
Executable File
303 lines
8.3 KiB
PHP
Executable File
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Database\Factories\CharacterFactory;
|
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
|
|
/**
|
|
* In-world avatar of a platform user (or NPC child without a user).
|
|
*
|
|
* Stores position, survival needs, employment and social state. The world
|
|
* ticker mutates needs and job progress continuously while nobody is online.
|
|
*/
|
|
#[Fillable([
|
|
'user_id', 'city_id', 'name', 'skin', 'hair', 'shirt',
|
|
'pos_x', 'pos_y', 'pos_z', 'money', 'energy', 'hunger', 'thirst', 'health',
|
|
'state', 'job_id', 'house_id', 'education', 'exercise_count', 'is_online', 'last_tick_at',
|
|
'hospitalized_until', 'face', 'beard', 'accessory', 'ride_kind', 'ride_until',
|
|
])]
|
|
class Character extends Model
|
|
{
|
|
/** @use HasFactory<CharacterFactory, $this> */
|
|
use HasFactory;
|
|
|
|
public const STATE_AWAKE = 'awake';
|
|
|
|
public const STATE_SLEEPING = 'sleeping';
|
|
|
|
public const STATE_WORKING = 'working';
|
|
|
|
public const STATE_STUDYING = 'studying';
|
|
|
|
/**
|
|
* Character is exercising on public plaza gym equipment.
|
|
*
|
|
* @var string
|
|
*/
|
|
public const STATE_EXERCISING = 'exercising';
|
|
|
|
/**
|
|
* Character admitted to the hospital after a traffic accident.
|
|
*
|
|
* @var string
|
|
*/
|
|
public const STATE_HOSPITAL = 'hospital';
|
|
|
|
/**
|
|
* Hospital stay length in seconds (one game hour).
|
|
*
|
|
* The day/night cycle is 240 real seconds per 24 game hours, so one
|
|
* game hour equals 10 real seconds.
|
|
*
|
|
* @var int
|
|
*/
|
|
public const HOSPITAL_STAY_SECONDS = 10;
|
|
|
|
/**
|
|
* Default hospital bed coordinates used when no hospital building exists.
|
|
* Kept mid-block (away from road lanes) so vehicles never hit the bed.
|
|
*
|
|
* @var array{0: float, 1: float}
|
|
*/
|
|
public const HOSPITAL_POS = [60.0, -51.0];
|
|
|
|
/**
|
|
* Owning user account (null for NPC children).
|
|
*
|
|
* @return BelongsTo<User, $this>
|
|
*/
|
|
public function user(): BelongsTo
|
|
{
|
|
return $this->belongsTo(User::class);
|
|
}
|
|
|
|
/**
|
|
* City where this character lives.
|
|
*
|
|
* @return BelongsTo<City, $this>
|
|
*/
|
|
public function city(): BelongsTo
|
|
{
|
|
return $this->belongsTo(City::class);
|
|
}
|
|
|
|
/**
|
|
* Current job position, when employed.
|
|
*
|
|
* @return BelongsTo<Job, $this>
|
|
*/
|
|
public function job(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Job::class);
|
|
}
|
|
|
|
/**
|
|
* Complete house owned by this character, when any.
|
|
*
|
|
* @return BelongsTo<Building, $this>
|
|
*/
|
|
public function house(): BelongsTo
|
|
{
|
|
return $this->belongsTo(Building::class, 'house_id');
|
|
}
|
|
|
|
/**
|
|
* Buildings constructed by this character.
|
|
*
|
|
* @return HasMany<Building, $this>
|
|
*/
|
|
public function buildings(): HasMany
|
|
{
|
|
return $this->hasMany(Building::class, 'owner_character_id');
|
|
}
|
|
|
|
/**
|
|
* Marketplace listings published by this character.
|
|
*
|
|
* @return HasMany<MarketListing, $this>
|
|
*/
|
|
public function listings(): HasMany
|
|
{
|
|
return $this->hasMany(MarketListing::class, 'seller_character_id');
|
|
}
|
|
|
|
/**
|
|
* Missions this character is tracking.
|
|
*
|
|
* @return BelongsToMany<Mission, $this>
|
|
*/
|
|
public function missions(): BelongsToMany
|
|
{
|
|
return $this->belongsToMany(Mission::class, 'character_missions')
|
|
->withPivot(['progress', 'is_completed', 'completed_at'])
|
|
->withTimestamps();
|
|
}
|
|
|
|
/**
|
|
* Inventory stacks owned by this character.
|
|
*
|
|
* @return HasMany<InventoryItem, $this>
|
|
*/
|
|
public function inventory(): HasMany
|
|
{
|
|
return $this->hasMany(InventoryItem::class);
|
|
}
|
|
|
|
/**
|
|
* Character of a linked user, when this account has one.
|
|
*/
|
|
public function forUser(int $userId): bool
|
|
{
|
|
return (int) $this->user_id === $userId;
|
|
}
|
|
|
|
/**
|
|
* Get or create the character for an approved user in the given city.
|
|
*/
|
|
public static function ensureForUser(User $user, ?City $city = null): self
|
|
{
|
|
$existing = self::query()->where('user_id', $user->id)->first();
|
|
if ($existing) {
|
|
return $existing;
|
|
}
|
|
|
|
$city ??= City::active();
|
|
abort_if($city === null, 500, 'No active city available.');
|
|
|
|
$firstName = trim(explode(' ', $user->name)[0] ?? $user->email);
|
|
|
|
$character = self::create([
|
|
'user_id' => $user->id,
|
|
'city_id' => $city->id,
|
|
'name' => $firstName,
|
|
'skin' => random_int(1, 4),
|
|
'hair' => random_int(1, 4),
|
|
'shirt' => random_int(1, 5),
|
|
'pos_x' => 0,
|
|
'pos_y' => 0,
|
|
'pos_z' => 25,
|
|
'money' => 100,
|
|
'energy' => 100,
|
|
'hunger' => 100,
|
|
'thirst' => 100,
|
|
'health' => 100,
|
|
'state' => self::STATE_AWAKE,
|
|
'education' => 0,
|
|
'exercise_count' => 0,
|
|
'is_online' => false,
|
|
'face' => random_int(1, 3),
|
|
'beard' => random_int(0, 2),
|
|
'accessory' => random_int(0, 2),
|
|
]);
|
|
|
|
// Starter kit so new citizens can eat, drink and build immediately.
|
|
foreach ([
|
|
InventoryItem::ITEM_FOOD => 3,
|
|
InventoryItem::ITEM_WATER => 3,
|
|
InventoryItem::ITEM_MATERIALS => 60,
|
|
] as $item => $qty) {
|
|
InventoryItem::create([
|
|
'character_id' => $character->id,
|
|
'item' => $item,
|
|
'qty' => $qty,
|
|
]);
|
|
}
|
|
|
|
return $character;
|
|
}
|
|
|
|
/**
|
|
* Get the attributes that should be cast.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
protected function casts(): array
|
|
{
|
|
return [
|
|
'pos_x' => 'float',
|
|
'pos_y' => 'float',
|
|
'pos_z' => 'float',
|
|
'money' => 'float',
|
|
'energy' => 'integer',
|
|
'hunger' => 'integer',
|
|
'thirst' => 'integer',
|
|
'health' => 'integer',
|
|
'education' => 'integer',
|
|
'exercise_count' => 'integer',
|
|
'is_online' => 'boolean',
|
|
'last_tick_at' => 'datetime',
|
|
'hospitalized_until' => 'datetime',
|
|
'face' => 'integer',
|
|
'beard' => 'integer',
|
|
'accessory' => 'integer',
|
|
'ride_until' => 'datetime',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Determine whether the character is currently admitted to the hospital.
|
|
*/
|
|
public function isHospitalized(): bool
|
|
{
|
|
return $this->state === self::STATE_HOSPITAL
|
|
&& $this->hospitalized_until !== null
|
|
&& $this->hospitalized_until->isFuture();
|
|
}
|
|
|
|
/**
|
|
* Admit the character to the hospital for one game hour after an accident.
|
|
*
|
|
* Teleports them to the city hospital bed and returns the bed position.
|
|
*
|
|
* @return array{0: float, 1: float}
|
|
*/
|
|
public function admitToHospital(): array
|
|
{
|
|
$hospital = Building::query()
|
|
->where('city_id', $this->city_id)
|
|
->where('type', Building::TYPE_HOSPITAL)
|
|
->where('is_complete', true)
|
|
->first();
|
|
|
|
$x = $hospital ? (float) $hospital->pos_x : self::HOSPITAL_POS[0];
|
|
$z = $hospital ? (float) ($hospital->pos_z + $hospital->depth / 2 + 3) : self::HOSPITAL_POS[1];
|
|
|
|
$this->forceFill([
|
|
'state' => self::STATE_HOSPITAL,
|
|
'hospitalized_until' => now()->addSeconds(self::HOSPITAL_STAY_SECONDS),
|
|
'pos_x' => $x,
|
|
'pos_z' => $z,
|
|
'pos_y' => 0,
|
|
])->save();
|
|
|
|
return [$x, $z];
|
|
}
|
|
|
|
/**
|
|
* Release the character from the hospital when the one-hour stay has elapsed.
|
|
*/
|
|
public function releaseIfHospitalStayOver(): bool
|
|
{
|
|
if ($this->state !== self::STATE_HOSPITAL) {
|
|
return false;
|
|
}
|
|
|
|
if ($this->hospitalized_until === null || $this->hospitalized_until->isPast()) {
|
|
$this->forceFill([
|
|
'state' => self::STATE_AWAKE,
|
|
'hospitalized_until' => null,
|
|
])->save();
|
|
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|