144 changes: 144 additions & 0 deletions code/components/glue/src/NUIFileDialog.cpp
@@ -0,0 +1,144 @@
#include <StdInc.h>

#if __has_include(<ConsoleHost.h>) && __has_include(<imgui.h>) && __has_include(<grcTexture.h>)
#include <ConsoleHost.h>
#include <CefOverlay.h>
#include <grcTexture.h>
#include <DrawCommands.h>
#include <nutsnbolts.h>

#include <json.hpp>

using json = nlohmann::json;

#define STB_IMAGE_IMPLEMENTATION
#include "imfd/ImFileDialog.h"

static std::mutex g_pfdMutex;
static std::map<std::string, std::shared_ptr<ifd::FileDialog>> g_pendingFileDialogs;
static std::set<void*> g_deleteTexQueue;

static InitFunction initFunction([]()
{
nui::OnInvokeNative.Connect([](const wchar_t* type, const wchar_t* arg)
{
if (wcscmp(type, L"openFileDialog") == 0)
{
if (!nui::HasMainUI())
{
return;
}

std::unique_lock _(g_pfdMutex);
if (g_pendingFileDialogs.empty())
{
ConHost::SetCursorMode(true);
}

auto id = std::make_shared<ifd::FileDialog>();
id->CreateTexture = [](uint8_t* data, int width, int height, char fmt) -> void*
{
if (width > 2048 || height > 2048)
{
return nullptr;
}

rage::grcTextureReference reference;
memset(&reference, 0, sizeof(reference));
reference.width = width;
reference.height = height;
reference.depth = 1;
reference.stride = width * 4;
reference.format = (fmt) ? 12 : 11;
reference.pixelData = (uint8_t*)data;

auto iconPtr = new void*[2];
iconPtr[0] = rage::grcTextureFactory::getInstance()->createImage(&reference, nullptr);
iconPtr[1] = nullptr;
return iconPtr;
};

id->DeleteTexture = [](void* tex)
{
g_deleteTexQueue.insert(tex);
};

id->Open(ToNarrow(arg), "Select a file", "Image files (*.png;*.jpg;*.jpeg;*.bmp){.png,.jpg,.jpeg,.bmp}");

g_pendingFileDialogs.insert({ ToNarrow(arg), id });
}
});

OnPostFrontendRender.Connect([]()
{
auto deleteThat = [](uintptr_t, uintptr_t)
{
std::unique_lock _(g_pfdMutex);

for (auto& entry : g_deleteTexQueue)
{
if (entry)
{
void** td = (void**)entry;
delete (rage::grcTexture*)td[0];
delete[] td;
}
}

g_deleteTexQueue.clear();
};

if (IsOnRenderThread())
{
deleteThat(0, 0);
}
});

ConHost::OnShouldDrawGui.Connect([](bool* should)
{
*should = *should || !g_pendingFileDialogs.empty();
});

ConHost::OnDrawGui.Connect([]()
{
std::unique_lock _(g_pfdMutex);

if (!g_pendingFileDialogs.empty())
{
auto nowPending = g_pendingFileDialogs;

for (auto& [ entryName, id ] : nowPending)
{
if (id->IsDone(entryName))
{
// has result?
json result = nullptr;

if (id->HasResult())
{
result = id->GetResult().u8string();
}

id->Close();

nui::PostFrameMessage("mpMenu", json::object({
{ "type", "fileDialogResult" },
{ "dialogKey", entryName },
{ "result", result }
}).dump());

// remove dialog
g_pendingFileDialogs.erase(entryName);

if (g_pendingFileDialogs.empty())
{
ConHost::SetCursorMode(false);
}
}
}
}
});
});

#include "imfd/ImFileDialog-inl.h"
#endif
1,475 changes: 1,475 additions & 0 deletions code/components/glue/src/imfd/ImFileDialog-inl.h

Large diffs are not rendered by default.

137 changes: 137 additions & 0 deletions code/components/glue/src/imfd/ImFileDialog.h
@@ -0,0 +1,137 @@
#pragma once
#include <ctime>
#include <stack>
#include <string>
#include <thread>
#include <vector>
#include <functional>
#include <filesystem>
#include <unordered_map>
#include <algorithm> // std::min, std::max

#define IFD_DIALOG_FILE 0
#define IFD_DIALOG_DIRECTORY 1
#define IFD_DIALOG_SAVE 2

namespace ifd {
class FileDialog {
public:
static inline FileDialog& Instance()
{
static FileDialog ret;
return ret;
}

FileDialog();
~FileDialog();

bool Save(const std::string& key, const std::string& title, const std::string& filter, const std::string& startingDir = "");

bool Open(const std::string& key, const std::string& title, const std::string& filter, bool isMultiselect = false, const std::string& startingDir = "");

bool IsDone(const std::string& key);

inline bool HasResult() { return m_result.size(); }
inline const std::filesystem::path& GetResult() { return m_result[0]; }
inline const std::vector<std::filesystem::path>& GetResults() { return m_result; }

void Close();

void RemoveFavorite(const std::string& path);
void AddFavorite(const std::string& path);
inline const std::vector<std::string>& GetFavorites() { return m_favorites; }

inline void SetZoom(float z) {
m_zoom = std::min<float>(25.0f, std::max<float>(1.0f, z));
m_refreshIconPreview();
}
inline float GetZoom() { return m_zoom; }

std::function<void*(uint8_t*, int, int, char)> CreateTexture; // char -> fmt -> { 0 = BGRA, 1 = RGBA }
std::function<void(void*)> DeleteTexture;

class FileTreeNode {
public:
FileTreeNode(const std::string& path) {
Path = std::filesystem::u8path(path);
Read = false;
}

std::filesystem::path Path;
bool Read;
std::vector<FileTreeNode*> Children;
};
class FileData {
public:
FileData(const std::filesystem::path& path);

std::filesystem::path Path;
bool IsDirectory;
size_t Size;
time_t DateModified;

bool HasIconPreview;
void* IconPreview;
uint8_t* IconPreviewData;
int IconPreviewWidth, IconPreviewHeight;
};

private:
std::string m_currentKey;
std::string m_currentTitle;
std::filesystem::path m_currentDirectory;
bool m_isMultiselect;
bool m_isOpen;
uint8_t m_type;
char m_inputTextbox[1024];
char m_pathBuffer[1024];
char m_newEntryBuffer[1024];
char m_searchBuffer[128];
std::vector<std::string> m_favorites;
bool m_calledOpenPopup;
std::stack<std::filesystem::path> m_backHistory, m_forwardHistory;
float m_zoom;

std::vector<std::filesystem::path> m_selections;
int m_selectedFileItem;
void m_select(const std::filesystem::path& path, bool isCtrlDown = false);

std::vector<std::filesystem::path> m_result;
bool m_finalize(const std::string& filename = "");

std::string m_filter;
std::vector<std::vector<std::string>> m_filterExtensions;
int m_filterSelection;
void m_parseFilter(const std::string& filter);

std::vector<int> m_iconIndices;
std::vector<std::string> m_iconFilepaths; // m_iconIndices[x] <-> m_iconFilepaths[x]
std::unordered_map<std::string, void*> m_icons;
void* m_getIcon(const std::filesystem::path& path);
void m_clearIcons();
void m_refreshIconPreview();
void m_clearIconPreview();

std::thread* m_previewLoader;
bool m_previewLoaderRunning;
void m_stopPreviewLoader();
void m_loadPreview();

std::vector<FileTreeNode*> m_treeCache;
void m_clearTree(FileTreeNode* node);
void m_renderTree(FileTreeNode* node);

unsigned int m_sortColumn;
unsigned int m_sortDirection;
std::vector<FileData> m_content;
void m_setDirectory(const std::filesystem::path& p, bool addHistory = true);
void m_sortContent(unsigned int column, unsigned int sortDirection);
void m_renderContent();

void m_renderPopups();
void m_renderFileDialog();
};

static const char* GetDefaultFolderIcon();
static const char* GetDefaultFileIcon();
}
21 changes: 21 additions & 0 deletions code/components/glue/src/imfd/LICENSE
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2021 dfranx

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
7,762 changes: 7,762 additions & 0 deletions code/components/glue/src/imfd/stb_image.h

Large diffs are not rendered by default.

22 changes: 22 additions & 0 deletions code/components/gta-core-five/src/CrashFixes.cpp
Expand Up @@ -523,8 +523,30 @@ void ShiftWeaponInfoBlobsDown(CWeaponInfoBlob* pArray[], uint16_t startIndex)
}
}

static const uint64_t* (*origMILookup)(void* archetype);

static const uint64_t* SafeMILookup(void* archetype)
{
auto mi = origMILookup(archetype);

if (!mi)
{
static const uint64_t noArchetype = 0xFFFFFFFFFFFFFFFF;
mi = &noArchetype;
}

return mi;
}

static HookFunction hookFunction{[] ()
{
// CModelInfoStreamingModule LookupModelId null return
{
auto location = hook::get_pattern("48 85 C0 74 2D 48 8B C8 E8 ? ? ? ? 8B 00", 8);
hook::set_call(&origMILookup, location);
hook::call(location, SafeMILookup);
}

// Clear out unused CWeaponInfoBlob when the array is shifted. This leads to a crash when another blob inserts in to the unused position
{
auto location = hook::get_pattern<char>("E8 ? ? ? ? FF CD 0F B7 05 ? ? ? ?");
Expand Down
41 changes: 24 additions & 17 deletions code/components/gta-core-five/src/FontFormatFixes.cpp
Expand Up @@ -267,11 +267,11 @@ static void GfxLog(void* sfLog, int messageType, const char* pfmt, va_list argLi

static void(*g_origGFxEditTextCharacterDef__SetTextValue)(void* self, const char* newText, bool html, bool notifyVariable);

static bool ContainsEmoji(const char* in);

static void GFxEditTextCharacterDef__SetTextValue(void* self, const char* newText, bool html, bool notifyVariable)
{
std::string textRef;

if (!html)
if (!html && ContainsEmoji(newText))
{
html = true;
}
Expand All @@ -285,26 +285,37 @@ struct GSizeF
float y;
};

static GSizeF (*getHtmlTextExtent)(void* self, const char* putf8Str, float width, const void* ptxtParams);
static void(*g_origFormatGtaText)(const char* in, char* out, bool a3, void* a4, float size, bool* html, int maxLength, bool a8);

static std::regex condRe{"&lt;(/?C)&gt;"};

static GSizeF GetHtmlTextExtentWrap(void* self, const char* putf8Str, float width, const void* ptxtParams)
static bool ContainsEmoji(const char* in)
{
// escape (since this is actually non-HTML text extent)
//std::string textRef = putf8Str;
//EscapeXml<char>(textRef);
static thread_local std::map<std::string, bool, std::less<>> emojiCache;
static thread_local uint32_t nextClear;

return getHtmlTextExtent(self, putf8Str, width, ptxtParams);
}
if (curTime > nextClear)
{
emojiCache.clear();
nextClear = curTime + 5000;
}

static void(*g_origFormatGtaText)(const char* in, char* out, bool a3, void* a4, float size, bool* html, int maxLength, bool a8);
if (auto it = emojiCache.find(in); it != emojiCache.end())
{
return it->second;
}

static std::regex condRe{"&lt;(/?C)&gt;"};
bool has = std::regex_search(ToWide(in), emojiRegEx);
emojiCache[in] = has;

return has;
}

static void FormatGtaTextWrap(const char* in, char* out, bool a3, void* a4, float size, bool* html, int maxLength, bool a8)
{
g_origFormatGtaText(in, out, a3, a4, size, html, maxLength, a8);

if (!*html)
if (!*html && ContainsEmoji(in))
{
*html = true;
}
Expand All @@ -331,10 +342,6 @@ static HookFunction hookFunction([]()

// GFxDrawTextImpl(?)

// GetTextExtent
hook::set_call(&getHtmlTextExtent, hook::get_pattern("48 8B 55 60 45 33 E4 4C 89", -0x5B));
hook::jump(hook::get_pattern("0F 29 70 D8 4D 8B F0 48 8B F2 0F 28 F3", -0x1F), GetHtmlTextExtentWrap);

OnGameFrame.Connect([]()
{
curTime = GetTickCount();
Expand Down
21 changes: 16 additions & 5 deletions code/components/gta-streaming-five/src/UnkStuff.cpp
Expand Up @@ -209,29 +209,40 @@ struct MinMax
{
float mins[4];
float maxs[4];

inline MinMax()
{
mins[0] = mins[1] = mins[2] = mins[3] = FLT_MAX;
maxs[0] = maxs[1] = maxs[2] = maxs[3] = 0.0f - FLT_MAX;
}
};

struct fwBoxStreamerVariable
{
char pad[256];
char pad[32];
atArray<MinMax> innerExtents;
char pad2[256 - 32 - 16];
atArray<MinMax> extents;
atArray<uint32_t> pendingList;
};

static void (*g_orig_fwBoxStreamerVariable_PostProcess)(fwBoxStreamerVariable* self);
static void (*g_orig_fwBoxStreamerVariable_PostProcessPending)(fwBoxStreamerVariable* self);

static void fwBoxStreamerVariable_PostProcess(fwBoxStreamerVariable* self)
{
auto extents = self->extents;
for (size_t index = 0; index < self->extents.GetCount(); index++)
{
self->extents[index] = self->innerExtents[index];
}

g_orig_fwBoxStreamerVariable_PostProcess(self);
self->extents = extents;
}

static void fwBoxStreamerVariable_PostProcessPending(fwBoxStreamerVariable* self)
{
auto extents = self->extents;
// backup/restore logic when used here ends up breaking some other map data
g_orig_fwBoxStreamerVariable_PostProcessPending(self);
self->extents = extents;
}

static HookFunction hookFunction([]()
Expand Down
27 changes: 27 additions & 0 deletions code/components/legacy-game-re3/src/Init.cpp
Expand Up @@ -8,8 +8,12 @@

#undef RemoveDirectory
#define RemoveDirectory RemoveDirectoryW

#undef GetModuleHandle
#define GetModuleHandle GetModuleHandleW
#endif

#include <CoreConsole.h>
#include <DrawCommands.h>
#include <grcTexture.h>
#include <VFSManager.h>
Expand All @@ -28,6 +32,18 @@
#include "Clock.h"
#include "Streaming.h"

static bool ReEnabled()
{
auto e = console::GetDefaultContext()->GetVariableManager()->FindEntryRaw("ui_customBackdrop");

if (e)
{
return e->GetValue().empty();
}

return true;
}

DLL_IMPORT ID3D11Device* GetRawD3D11Device();

struct Re3InitStruct
Expand Down Expand Up @@ -191,13 +207,24 @@ static InitFunction initFunction([]()
{
if (wcscmp(type, L"enterGameplay") == 0)
{
if (!ReEnabled())
{
nui::PostFrameMessage("mpMenu", R"({ "type": "exitGameplay" })");
return;
}

nui::SetHideCursor(true);
bWantsGameplay = true;
}
});

OnPostFrontendRender.Connect([]()
{
if (!ReEnabled())
{
return;
}

static rage::grcTexture* gTex = NULL;

static bool inited = false;
Expand Down
4 changes: 2 additions & 2 deletions ext/cfx-ui/src/app/app.component.html
@@ -1,7 +1,7 @@
<div [class]="classes">
<div id="bg" class="acrylicBlur">
<div id="bg" class="acrylicBlur" [style.background-image]="stylish">
<canvas
*ngIf="!showSiteNavbar && gameName != 'rdr3' && gameName != 'ny'"
*ngIf="!showSiteNavbar && gameName != 'rdr3' && gameName != 'ny' && customBackdrop == ''"
#gameCanvas
>
</canvas>
Expand Down
18 changes: 18 additions & 0 deletions ext/cfx-ui/src/app/app.component.ts
Expand Up @@ -11,6 +11,9 @@ import { ServersService } from './servers/servers.service';
import { L10N_LOCALE, L10nLocale, L10nTranslationService } from 'angular-l10n';
import { OverlayContainer } from '@angular/cdk/overlay';
import { getNavConfigFromUrl } from './nav/helpers';
import { DomSanitizer } from '@angular/platform-browser';

import * as md5 from 'js-md5';

// from fxdk
const vertexShaderSrc = `
Expand Down Expand Up @@ -185,6 +188,8 @@ export class AppComponent implements OnInit, AfterViewInit {
@ViewChild('gameCanvas')
gameCanvas: ElementRef;

customBackdrop = '';

gameView: ReturnType<typeof createGameView>;

get minMode() {
Expand All @@ -207,6 +212,7 @@ export class AppComponent implements OnInit, AfterViewInit {
private zone: NgZone,
private serversService: ServersService,
private overlayContainer: OverlayContainer,
private sanitizer: DomSanitizer,
) {
this.gameService.init();

Expand All @@ -227,6 +233,10 @@ export class AppComponent implements OnInit, AfterViewInit {
this.minModeSetUp = true;
});

this.gameService.getConvar('ui_customBackdrop').subscribe((value: string) => {
this.customBackdrop = value;
});

this.gameService.getConvar('ui_blurPerfMode').subscribe((value: string) => {
delete this.classes['blur-noBackdrop'];
delete this.classes['blur-noBlur'];
Expand Down Expand Up @@ -307,6 +317,14 @@ export class AppComponent implements OnInit, AfterViewInit {
});
}

get stylish() {
if (this.customBackdrop) {
return this.sanitizer.bypassSecurityTrustUrl(`url(https://nui-backdrop/user.png?${md5(this.customBackdrop)})`);
}

return null;
}

ngAfterViewInit(): void {
if (!this.gameCanvas) {
return;
Expand Down
23 changes: 23 additions & 0 deletions ext/cfx-ui/src/app/game.service.ts
Expand Up @@ -183,6 +183,10 @@ export abstract class GameService {

abstract toggleListEntry(type: string, server: Server, isInList: boolean): void;

async selectFile(key: string): Promise<string> {
throw new Error('not on web');
}

sayHello() {}

getProfile(): Profile {
Expand Down Expand Up @@ -394,6 +398,9 @@ export class CfxGameService extends GameService {
case 'exitGameplay':
document.body.style.visibility = 'visible';
break;
case 'fileDialogResult':
this.zone.run(() => this.invokeFileDialogResult(event.data.dialogKey, event.data.result));
break;
case 'connectFailed':
this.zone.run(() => this.invokeConnectFailed(this.lastServer, event.data.message));
break;
Expand Down Expand Up @@ -590,6 +597,22 @@ export class CfxGameService extends GameService {
}
}

private fileSelectReqs: { [key: string]: (result: string) => void } = {};

private invokeFileDialogResult(key: string, result?: string) {
if (this.fileSelectReqs[key]) {
this.fileSelectReqs[key](result ?? '');
}
}

async selectFile(key: string): Promise<string> {
return new Promise<string>((resolve) => {
(window as any).invokeNative('openFileDialog', key);

this.fileSelectReqs[key] = resolve;
});
}

protected invokeBuildSwitchRequest(server: Server, build: number) {
this.card = true;

Expand Down
@@ -1,6 +1,6 @@
import {
Component, Input, ViewChild, ChangeDetectionStrategy,
OnDestroy, OnInit, ElementRef, AfterViewInit, NgZone, Renderer2, OnChanges
OnDestroy, OnInit, ElementRef, AfterViewInit, NgZone, Renderer2, OnChanges, ChangeDetectorRef
} from '@angular/core';
import { Router } from '@angular/router';

Expand All @@ -14,6 +14,7 @@ import { ServersService } from '../../servers.service';

import parseAPNG, { isNotAPNG } from '@citizenfx/apng-js';
import { ServerTagsService } from 'app/servers/server-tags.service';
import { Subscription } from 'rxjs';

@Component({
moduleId: module.id,
Expand All @@ -40,9 +41,12 @@ export class ServersListItemComponent implements OnInit, OnChanges, OnDestroy, A

private upvoting = false;

private tagSubscription: Subscription;

constructor(private gameService: GameService, private discourseService: DiscourseService, private tagService: ServerTagsService,
private serversService: ServersService, private router: Router, private elementRef: ElementRef,
private zone: NgZone, private renderer: Renderer2) { }
private zone: NgZone, private renderer: Renderer2, private cdr: ChangeDetectorRef) {
}

get premium() {
if (!this.server.data.vars) {
Expand All @@ -60,10 +64,14 @@ export class ServersListItemComponent implements OnInit, OnChanges, OnDestroy, A
this.hoverIntent.options({
interval: 50
});

this.tagSubscription = this.tagService.onUpdate.subscribe(() => this.cdr.detectChanges());
}

public ngOnDestroy() {
this.hoverIntent.remove();

this.tagSubscription.unsubscribe();
}

public ngAfterViewInit() {
Expand Down
13 changes: 13 additions & 0 deletions ext/cfx-ui/src/app/settings.service.ts
Expand Up @@ -70,6 +70,14 @@ export class SettingsService {
category: '#SettingsCat_Interface',
});

this.addSetting('customBackdropButton', {
name: '#Settings_CustomBackdrop',
type: 'button',
setCb: (value) => this.setCustomBackdrop(),
category: '#SettingsCat_Interface',
description: '#Settings_CustomBackdropSelect',
});

this.addSetting('uiPerformance', {
name: '#Settings_LowPerfMode',
description: '#Settings_LowPerfModeDesc',
Expand Down Expand Up @@ -282,4 +290,9 @@ export class SettingsService {
const url = await this.discourseService.generateAuthURL();
this.gameService.openUrl(url);
}

private async setCustomBackdrop() {
const fileName = await this.gameService.selectFile('backdrop');
this.gameService.setArchivedConvar('ui_customBackdrop', fileName);
}
}
14 changes: 8 additions & 6 deletions ext/cfx-ui/src/assets/languages/locale-en.json
Expand Up @@ -40,8 +40,8 @@
"#Settings_Nickname": "Player name",
"#Settings_LocalhostPort": "Custom port",
"#Settings_LocalhostPort2": "Localhost port",
"#Settings_DarkTheme": "\"Dark\" \"theme\"",
"#Settings_DarkThemeDesc": "Make the interface use a \"dark\" \"theme\"",
"#Settings_DarkTheme": "\"Dark\" color scheme",
"#Settings_DarkThemeDesc": "Make the interface use a \"dark\" color scheme",
"#Settings_StreamerMode": "Streamer mode",
"#Settings_StreamerModeDesc": "Hide sensitive information (e.g. IP addresses) from screen",
"#Settings_DevMode": "Dev mode",
Expand All @@ -56,17 +56,17 @@
"#Settings_MenuAudio": "Main menu audio",
"#Settings_MenuAudioDesc": "Enable background music while idle in the main menu",
"#Settings_GameStreamProgress": "Streaming progress",
"#Settings_GameStreamProgressDesc": "Display progress when downloading in-game streamed assets (BETAâ„¢ - not recommended for systems with HDD)",
"#Settings_GameStreamProgressDesc": "Display progress when downloading in-game streamed assets (not recommended for systems with HDD)",
"#Settings_UseAudioFrameLimiter": "Audio frame limiter",
"#Settings_UseAudioFrameLimiterDesc": "Enable rage::g_audUseFrameLimiter — disabling this may help with hitches at high frame rates",
"#Settings_UseAudioFrameLimiterDesc": "Fix hitches at high frame rates by disabling rage::g_audUseFrameLimiter",
"#Settings_HandbrakeCamera": "Handbrake camera",
"#Settings_HandbrakeCameraDesc": "Enable automatic recentering of vehicle-follow camera when using the handbrake",
"#Settings_HandbrakeCameraDesc": "Automatically recenter the vehicle-follow camera when using the handbrake",
"#Settings_QueriesPerMinute": "Server browser: Max pings / minute",
"#Settings_CustomEmoji": "Custom FiveM watermark emoji",
"#Settings_CustomEmojiDesc": "An emoji to show in the FiveM watermark.",
"#Settings_CustomEmojiUpsell": "Unavailable! Pledge $5/month or more to the FiveM Patreon and link your FiveM account to set a custom emoji for the FiveM watermark.",
"#Settings_InProcessGpu": "NUI in-process GPU",
"#Settings_InProcessGpuDesc": "Enable NUI in-process GPU — fixes 'UI lag' at high GPU usage, but may cause reliability issues with GPU crashes (requires restart)",
"#Settings_InProcessGpuDesc": "Fix 'UI lag' at high GPU usage, but may cause reliability issues with GPU crashes (requires restart)",
"#Settings_ConnectedProfiles": "Linked identities",
"#Settings_UpdateChannel": "Update channel",
"#Settings_UpdateChannelDesc": "Try out new features and changes before they launch. Has a chance of breaking.",
Expand All @@ -75,6 +75,8 @@
"#Settings_LowPerf_Off": "Off",
"#Settings_LowPerf_ReduceBackdrop": "Backdrop only",
"#Settings_LowPerf_ReduceFull": "No blur",
"#Settings_CustomBackdrop": "Custom menu backdrop",
"#Settings_CustomBackdropSelect": "Select",
"#SettingsCat_Connection": "Connection",
"#SettingsCat_Interface": "Interface",
"#SettingsCat_Game": "Game",
Expand Down
3 changes: 2 additions & 1 deletion ext/sdk/resources/sdk-game/src/client/connectedSender.ts
@@ -1,8 +1,9 @@
import { sendSdkMessage } from "./sendSdkMessage";
import { sendSdkBackendMessage, sendSdkMessage } from "./sendSdkMessage";

on('onClientResourceStart', (resourceName: string) => {
if (resourceName === 'sdk-game') {
sendSdkMessage('connected');
sendSdkBackendMessage('connected');
}
});

Expand Down
14 changes: 13 additions & 1 deletion ext/sdk/resources/sdk-game/src/client/sendSdkMessage.ts
@@ -1,5 +1,17 @@
import { joaat } from "../shared";

const SEND_SDK_MESSAGE = joaat('SEND_SDK_MESSAGE');
const SEND_SDK_MESSAGE_TO_BACKEND = joaat('SEND_SDK_MESSAGE_TO_BACKEND');

export function sendSdkMessage(type: string, data?: any) {
Citizen.invokeNative("0x21550059", JSON.stringify({
Citizen.invokeNative(SEND_SDK_MESSAGE, JSON.stringify({
type,
data,
}));
}

export function sendSdkBackendMessage(type: string, data?: any) {
Citizen.invokeNative(SEND_SDK_MESSAGE_TO_BACKEND, JSON.stringify({
type,
data,
}));
Expand Down
15 changes: 9 additions & 6 deletions ext/sdk/resources/sdk-game/src/world-editor/camera-controller.ts
Expand Up @@ -36,14 +36,17 @@ export const CameraController = new class CameraController {
});
}

private destroyed = false;
destroy() {
if (IsCamActive(this.handle)) {
DestroyCam(this.handle, false);
ClearFocus();
SetPlayerControl(PlayerId(), true, 0);

RenderScriptCams(false, false, 0, false, false);
if (this.destroyed) {
return;
}

RenderScriptCams(false, false, 0, false, false);
SetPlayerControl(PlayerId(), true, 0);
ClearFocus();

DestroyCam(this.handle, false);
}

setMoveX(x: number) {
Expand Down
@@ -1,3 +1,4 @@
import { sendSdkMessage } from '../client/sendSdkMessage';
import { CameraController } from './camera-controller';
import './environment-manager';
import { PreviewManager } from './preview-manager';
Expand All @@ -14,8 +15,15 @@ setTimeout(() => {
});

ShutdownLoadingScreen();
DoScreenFadeIn(0);

sendSdkMessage('we:ready');
}, 0);

on('disconnecting', () => {
CameraController.destroy();
});

on('onResourceStop', (resourceName: string) => {
if (resourceName === GetCurrentResourceName()) {
CameraController.destroy();
Expand Down
Expand Up @@ -2,11 +2,19 @@ import * as React from 'react';
import { ReactWidget } from '@theia/core/lib/browser';
import { AbstractViewContribution } from '@theia/core/lib/browser';
import { inject, injectable, postConstruct } from 'inversify';
import { FxdkDataService } from 'fxdk-services/lib/browser/fxdk-data-service';
import { FxdkDataService, GameStates } from 'fxdk-services/lib/browser/fxdk-data-service';

import './common/game-view.webcomponent.js';

const GameView = React.memo(() => {
const gameStateToCaption: Record<GameStates, string> = {
[GameStates.NOT_RUNNING]: 'not running',
[GameStates.READY]: 'ready',
[GameStates.LOADING]: 'loading',
[GameStates.UNLOADING]: 'unloading',
[GameStates.CONNECTED]: 'active'
};

const GameView = React.memo(({ gameState }: { gameState: GameStates }) => {
const [pointerLocked, setPointerLocked] = React.useState(false);
const gameViewRef = React.useRef<any>(null);

Expand Down Expand Up @@ -58,21 +66,39 @@ export class FxdkGameView extends ReactWidget {
protected readonly dataService: FxdkDataService;

private theiaIsActive = false;
private gameState = GameStates.NOT_RUNNING;

@postConstruct()
init(): void {
this.id = FxdkGameView.ID;
this.title.closable = true;
this.title.caption = 'Game view';
this.title.label = 'Game view';
this.title.iconClass = 'fa fa-gamepad';
this.update();

this.theiaIsActive = this.dataService.getTheiaIsActive();
this.toDispose.push(this.dataService.onTheiaIsActiveChange((theiaIsActive) => {
this.theiaIsActive = theiaIsActive;
this.update();
}));

this.gameState = this.dataService.getGameState();
this.title.label = 'Game - ' + gameStateToCaption[this.gameState];

this.toDispose.push(this.dataService.onGameStateChange((gameState) => {
this.title.label = 'Game - ' + gameStateToCaption[gameState];
this.title.caption = gameStateToCaption[gameState];

if (gameState === GameStates.LOADING || gameState === GameStates.UNLOADING) {
this.title.iconClass = 'fa fa-spinner fa-spin';
} else {
this.title.iconClass = 'fa fa-gamepad';
}

this.gameState = gameState;
this.update();
}));

this.update();
}

protected onActivateRequest() {
Expand All @@ -89,7 +115,7 @@ export class FxdkGameView extends ReactWidget {
}

return (
<GameView />
<GameView gameState={this.gameState} />
);
}
}
Expand Down
Expand Up @@ -8,7 +8,7 @@ import { CommandService } from '@theia/core';

import { FxdkGameView, FxdkGameViewContribution } from 'fxdk-game-view/lib/browser/fxdk-game-view-view';
import { FrontendApplicationState, FrontendApplicationStateService } from '@theia/core/lib/browser/frontend-application-state';
import { ClientResourceData, FxdkDataService, ServerResourceData, StructuredMessage } from 'fxdk-services/lib/browser/fxdk-data-service';
import { ClientResourceData, FxdkDataService, GameStates, ServerResourceData, StructuredMessage } from 'fxdk-services/lib/browser/fxdk-data-service';

const stateToNumber: Record<FrontendApplicationState, number> = {
init: 0,
Expand Down Expand Up @@ -117,6 +117,10 @@ export class FxdkProjectContribution implements FrontendApplicationContribution
this.dataService.setTheiaIsActive(isActive);
}

private handleSetGameState(gameState: GameStates) {
this.dataService.setGameState(gameState);
}

private handleOpenFile(file: string) {
if (!this.reachedState('ready')) {
return;
Expand Down
@@ -1,6 +1,13 @@
import { Disposable } from '@theia/core';
import { injectable } from 'inversify';

export enum GameStates {
NOT_RUNNING,
READY,
LOADING,
CONNECTED,
UNLOADING,
}

export class SingleEventEmitter<T> {
private listeners: Set<(arg: T) => void> = new Set();
Expand Down Expand Up @@ -155,4 +162,20 @@ export class FxdkDataService {
getTheiaIsActive(): boolean {
return this.theiaIsActive;
}

private gameState = GameStates.NOT_RUNNING;
private gameStateChangeEvent = new SingleEventEmitter<GameStates>();

onGameStateChange(cb: (gameState: GameStates) => void): Disposable {
return this.gameStateChangeEvent.on(cb);
}

getGameState(): GameStates {
return this.gameState;
}

setGameState(gameState: GameStates) {
this.gameState = gameState;
this.gameStateChangeEvent.emit(this.gameState);
}
}
Expand Up @@ -10,7 +10,7 @@ export const FXWorld = observer(function FXWorld(props: ProjectItemProps) {
const assetPath = props.entry.path;

const handleOpen = React.useCallback(() => {
WorldEditorState.openMap(assetPath);
WorldEditorState.openMap(props.entry);
}, [assetPath]);

return (
Expand Down
Expand Up @@ -69,6 +69,11 @@ export class GameServerService implements AppContribution, ApiContribution {
return this.serverStopEvent.addListener(cb);
}

private readonly serverStateChangeEvent = new SingleEventEmitter<ServerStates>();
onServerStateChange(cb: (serverStart: ServerStates) => void): Disposable {
return this.serverStateChangeEvent.addListener(cb);
}

private disposeServer() {
if (this.server) {
this.server.dispose();
Expand Down Expand Up @@ -183,6 +188,8 @@ export class GameServerService implements AppContribution, ApiContribution {
if (this.server) {
this.lock();

this.gameService.beginUnloading();

try {
this.stopTask = this.taskReporterService.create('Stopping server');
await this.server.stop(this.stopTask);
Expand All @@ -197,7 +204,12 @@ export class GameServerService implements AppContribution, ApiContribution {
}

toState(newState: ServerStates) {
if (this.state === newState) {
return;
}

this.state = newState;
this.serverStateChangeEvent.emit(this.state);
this.ackState();
}

Expand Down
@@ -1,11 +1,14 @@
import { bindApiContribution } from "backend/api/api-contribution";
import { bindAppContribution } from "backend/app/app-contribution";
import { interfaces } from "inversify";
import { GameConnectionService } from "./game-connection-service";
import { GameService } from "./game-service";

export const bindGame = (container: interfaces.Container) => {
container.bind(GameService).toSelf().inSingletonScope();

bindAppContribution(container, GameService);
bindApiContribution(container, GameService);

container.bind(GameConnectionService).toSelf().inSingletonScope();
bindAppContribution(container, GameConnectionService);
};
@@ -0,0 +1,69 @@
import { AppContribution } from "backend/app/app-contribution";
import { GameServerService } from "backend/game-server/game-server-service";
import { LogService } from "backend/logger/log-service";
import { inject, injectable } from "inversify";
import { ServerStates } from "shared/api.types";
import { GameStates } from "./game-contants";
import { GameService } from "./game-service";

@injectable()
export class GameConnectionService implements AppContribution {
@inject(LogService)
protected readonly logService: LogService;

@inject(GameService)
protected readonly gameService: GameService;

@inject(GameServerService)
protected readonly gameServerService: GameServerService;

private gameFPSLimited = false;

boot() {
this.gameService.onGameStateChange((gameState) => {
this.processState(gameState, this.gameServerService.getState());
});

this.gameServerService.onServerStateChange((serverState) => {
this.processState(this.gameService.getGameState(), serverState);
});
}

private processState(gameState: GameStates, serverState: ServerStates) {
switch (true) {
case gameState === GameStates.READY && serverState === ServerStates.up: {
return emit('sdk:connectClientTo', '127.0.0.1:30120');
}

case gameState === GameStates.CONNECTED && serverState === ServerStates.up: {
return this.unlimitFPS();
}

case gameState === GameStates.UNLOADING && serverState === ServerStates.down: {
return emit('sdk:disconnectClient');
}

default: {
return this.limitFPS();
}
}
}

private limitFPS() {
if (this.gameFPSLimited) {
return;
}

this.gameFPSLimited = true;
emit('sdk:setFPSLimit', 60);
}

private unlimitFPS() {
if (!this.gameFPSLimited) {
return;
}

this.gameFPSLimited = false;
emit('sdk:setFPSLimit', 0);
}
}
@@ -0,0 +1,8 @@
// If modifying, don't forget to change it at sdk/resources/sdk-root/personality-theia/fxdk-services/src/browser/fxdk-data-service.ts as well
export enum GameStates {
NOT_RUNNING,
READY,
LOADING,
CONNECTED,
UNLOADING,
}
90 changes: 84 additions & 6 deletions ext/sdk/resources/sdk-root/shell/src/backend/game/game-service.ts
Expand Up @@ -2,10 +2,14 @@ import { ApiClient } from "backend/api/api-client";
import { ApiContribution } from "backend/api/api-contribution";
import { handlesClientEvent } from "backend/api/api-decorators";
import { AppContribution } from "backend/app/app-contribution";
import { Disposable, disposableFromFunction } from "backend/disposable-container";
import { LogService } from "backend/logger/log-service";
import { NotificationService } from "backend/notification/notification-service";
import { inject, injectable } from "inversify";
import { gameApi } from "shared/api.events";
import { NetLibraryConnectionState, SDKGameProcessState } from "shared/native.enums";
import { SingleEventEmitter } from "utils/singleEventEmitter";
import { GameStates } from "./game-contants";

@injectable()
export class GameService implements ApiContribution, AppContribution {
Expand All @@ -16,21 +20,39 @@ export class GameService implements ApiContribution, AppContribution {
@inject(ApiClient)
protected readonly apiClient: ApiClient;

@inject(LogService)
protected readonly logService: LogService;

@inject(NotificationService)
protected readonly notificationService: NotificationService;

private gameBuildNumber: number = 0;
private gameState = GameStates.NOT_RUNNING;

private gameBuildNumber = 0;

private gameLaunched = false;

private gameUnloaded = true;

private gameLaunched: boolean = false;
private gameProcessState = SDKGameProcessState.GP_STOPPED;

private gameProcessState: SDKGameProcessState = SDKGameProcessState.GP_STOPPED;
private connectionState = NetLibraryConnectionState.CS_IDLE;

private connectionState: NetLibraryConnectionState = NetLibraryConnectionState.CS_IDLE;
private restartPending = false;

private readonly gameStateChangeEvent = new SingleEventEmitter<GameStates>();
onGameStateChange(cb: (gameState: GameStates) => void): Disposable {
return this.gameStateChangeEvent.addListener(cb);
}

getBuildNumber(): number {
return this.gameBuildNumber;
}

getGameState(): GameStates {
return this.gameState;
}

async boot() {
const gameBuildNumberPromise = new Promise<void>((resolve) => {
const handler = (gameBuildNumber: number) => {
Expand All @@ -47,13 +69,24 @@ export class GameService implements ApiContribution, AppContribution {

on('sdk:gameLaunched', () => {
this.gameLaunched = true;
this.gameUnloaded = true;
this.toGameState(GameStates.READY);

this.apiClient.emit(gameApi.gameLaunched, true);
});

on('sdk:connectionStateChanged', (current: NetLibraryConnectionState, previous: NetLibraryConnectionState) => {
this.connectionState = current;

if (this.gameLaunched) {
if (current === NetLibraryConnectionState.CS_IDLE) {
this.toGameState(GameStates.UNLOADING);
} else if (this.gameUnloaded) {
this.gameUnloaded = false;
this.toGameState(GameStates.LOADING);
}
}

this.apiClient.emit(gameApi.connectionStateChanged, { current, previous });
});

Expand All @@ -65,27 +98,51 @@ export class GameService implements ApiContribution, AppContribution {

this.gameLaunched = false;
this.connectionState = NetLibraryConnectionState.CS_IDLE;
this.toGameState(GameStates.NOT_RUNNING);

this.apiClient.emit(gameApi.connectionStateChanged, { current: this.connectionState, previous: previousConnectionState });
this.apiClient.emit(gameApi.gameLaunched, this.gameLaunched);
}

if (current === SDKGameProcessState.GP_STOPPED) {
this.notificationService.error('It looks like game has crashed, restarting it now', 5000);
this.startGame();
if (this.restartPending) {
this.restartPending = false;
} else {
this.notificationService.error('It looks like game has crashed, restarting it now', 5000);
this.startGame();
}
}

this.apiClient.emit(gameApi.gameProcessStateChanged, { current, previous });
});

on('sdk:gameUnloaded', () => {
this.gameUnloaded = true;

if (this.gameLaunched) {
this.toGameState(GameStates.READY);
}
});

on('sdk:backendMessage', (message: string) => {
if (this.gameLaunched && message === JSON.stringify({ type: 'connected' })) {
this.toGameState(GameStates.CONNECTED);
}
});

this.startGame();

await gameBuildNumberPromise;
}

beginUnloading() {
this.toGameState(GameStates.UNLOADING);
}

@handlesClientEvent(gameApi.ack)
ack() {
this.apiClient.emit(gameApi.ack, {
gameState: this.gameState,
gameLaunched: this.gameLaunched,
gameProcessState: this.gameProcessState,
connectionState: this.connectionState,
Expand All @@ -99,11 +156,32 @@ export class GameService implements ApiContribution, AppContribution {

@handlesClientEvent(gameApi.stop)
stopGame() {
this.gameLaunched = false;
this.gameUnloaded = true;

this.toGameState(GameStates.NOT_RUNNING);

emit('sdk:stopGame');
}

@handlesClientEvent(gameApi.restart)
restartGame() {
this.restartPending = true;
this.gameLaunched = false;
this.gameUnloaded = true;

this.toGameState(GameStates.NOT_RUNNING);

emit('sdk:restartGame');
}

private toGameState(newState: GameStates) {
if (newState === this.gameState) {
return;
}

this.gameState = newState;
this.gameStateChangeEvent.emit(this.gameState);
this.apiClient.emit(gameApi.gameState, this.gameState);
}
}
@@ -1,14 +1,19 @@
import * as React from 'react';
import classnames from 'classnames';
import s from './Indicator.module.scss';

const delay1 = { animationDelay: '0ms' };
const delay2 = { animationDelay: '50ms' };
const delay3 = { animationDelay: '100ms' };
const delay4 = { animationDelay: '150ms' };

export const Indicator = React.memo(function Indicator() {
export interface IndicatorProps {
className?: string,
}

export const Indicator = React.memo(function Indicator({ className }: IndicatorProps) {
return (
<div className={s.root}>
<div className={classnames(s.root, className)}>
<div style={delay1} />
<div style={delay2} />
<div style={delay3} />
Expand Down
Expand Up @@ -21,14 +21,14 @@
}

&.up {
background-color: $scColor;
background-color: $scColor !important;

svg {
color: $bgColor;
color: $bgColor !important;
}

&:hover {
background-color: $erColor;
background-color: $erColor !important;
}
}

Expand All @@ -39,6 +39,6 @@
}

&.error {
background-color: $erColor;
background-color: $erColor !important;
}
}
Expand Up @@ -34,9 +34,6 @@
100% {
background-position-x: 100%;
}
// 100% {
// background-position-x: 0%;
// }
}

animation: slidingStripe .5s linear infinite;
Expand Down
2 changes: 0 additions & 2 deletions ext/sdk/resources/sdk-root/shell/src/index.tsx
Expand Up @@ -10,7 +10,6 @@ import { Shell } from './components/Shell';
import { enableLogger } from 'utils/logger';
import { TitleManager } from 'managers/TitleManager';
import { TheiaProjectManager } from 'managers/TheiaProjectManager';
import { GameConnectionManager } from 'managers/GameConnectionManager';
import { onApiMessage } from 'utils/api';
import { stateApi } from 'shared/api.events';

Expand Down Expand Up @@ -59,7 +58,6 @@ document.addEventListener('contextmenu', (event) => {

ReactDOM.render(
<React.StrictMode>
<GameConnectionManager />
<TitleManager />
<TheiaProjectManager />

Expand Down

This file was deleted.

17 changes: 12 additions & 5 deletions ext/sdk/resources/sdk-root/shell/src/managers/TitleManager.tsx
@@ -1,6 +1,7 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import { ProjectState } from 'store/ProjectState';
import { WorldEditorState } from 'personalities/WorldEditorPersonality/WorldEditorState';

const titleBase = 'Cfx.re Development Kit (FiveM)';

Expand All @@ -9,13 +10,19 @@ export const TitleManager = observer(() => {

React.useEffect(() => {
if (titleRef.current) {
const title = ProjectState.hasProject
? `${ProjectState.projectName} — ${titleBase}`
: titleBase;
const parts = [titleBase];

titleRef.current.innerText = title;
if (ProjectState.hasProject) {
parts.push(ProjectState.projectName);
}

if (WorldEditorState.mapName) {
parts.push(WorldEditorState.mapName);
}

titleRef.current.innerText = parts.reverse().join(' — ');
}
}, [ProjectState.hasProject, ProjectState.projectName]);
}, [ProjectState.hasProject, ProjectState.projectName, WorldEditorState.mapName]);

React.useLayoutEffect(() => {
titleRef.current = document.querySelector('title');
Expand Down
Expand Up @@ -5,6 +5,7 @@ import { serverApi } from "shared/api.events";
import { onWindowEvent, onWindowMessage } from "utils/windowMessages";
import { GameState } from "store/GameState";
import { ShellPersonality, ShellState } from "store/ShellState";
import { GameStates } from "backend/game/game-contants";

const theiaRef = React.createRef<HTMLIFrameElement>();

Expand Down Expand Up @@ -74,6 +75,17 @@ export const TheiaState = new class TheiaState {
},
);

reaction(
() => GameState.state,
(gameState: GameStates) => {
this.setGameState(gameState);

if (gameState === GameStates.LOADING) {
this.openGameView();
}
},
);

// Also proxying all window messages to theia
onWindowMessage((data) => this.sendMessage(data));
}
Expand All @@ -83,6 +95,7 @@ export const TheiaState = new class TheiaState {
this.isReady = ready;
if (ready) {
this.setIsActive(ShellState.personality === ShellPersonality.THEIA);
this.setGameState(GameState.state);
}
};

Expand Down Expand Up @@ -119,6 +132,13 @@ export const TheiaState = new class TheiaState {
});
}

setGameState(gameState: GameStates) {
this.sendMessage({
type: 'fxdk:setGameState',
data: gameState,
});
}

sendMessage(msg: any) {
const { container } = this;

Expand Down
@@ -0,0 +1,61 @@
@import "../vars.scss";

.root {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;

position: fixed;

top: 0;
left: 0;
right: 0;
bottom: 0;

background-color: $bgColor;

.text {
margin-top: $q*4;

@include fontSecondary;
font-size: $fs3;

user-select: none;
}

.progress {
width: 25vw;
height: $q*2;

margin-top: $q*4;

background-color: rgba($fgColor, .25);

overflow: hidden;

&.indeterminate {
background-image: linear-gradient(90deg, transparent, #{$acColor}, transparent);
background-size: 50%;

@keyframes slidingStripe {
0% {
background-position-x: 0%;
}
100% {
background-position-x: 100%;
}
}

animation: slidingStripe .5s linear infinite;
}

.bar {
height: 100%;

background-color: $acColor;

transition: width .2s ease;
}
}
}
@@ -0,0 +1,42 @@
import React from 'react';
import { observer } from 'mobx-react-lite';
import classnames from 'classnames';
import { GameState } from 'store/GameState';
import { GameLoadingState } from 'store/GameLoadingState';
import { GameStates } from 'backend/game/game-contants';
import s from './LoadScreen.module.scss';

function getLoadingText() {
if (GameState.state === GameStates.READY) {
return 'The World Editor is preparing to load...';
}

if (GameState.state === GameStates.UNLOADING) {
return 'The World Editor is getting ready...';
}

return 'Loading World Editor...';
}

export const LoadScreen = observer(function LoadScreen() {
const text = getLoadingText();

const progress = GameState.state !== GameStates.LOADING
? '0%'
: `${GameLoadingState.loadingProgress * 100}%`;
const progressClassName = classnames(s.progress, {
[s.indeterminate]: GameLoadingState.loadingProgress === 0 || GameState.state !== GameStates.LOADING,
});

return (
<div className={classnames(s.root, 'animated-background')}>
<div className={s.text}>
{text}
</div>

<div className={progressClassName}>
<div className={s.bar} style={{ width: progress }} />
</div>
</div>
);
});
Expand Up @@ -17,7 +17,7 @@

display: flex;

top: $editorToolbarHeight;
top: 0;
left: 0;
right: 0;
bottom: 0;
Expand Down
Expand Up @@ -6,6 +6,7 @@ import { GameView } from './GameView/GameView';
import { WorldEditorState } from './WorldEditorState';
import { WorldEditorToolbar } from './WorldEditorToolbar/WorldEditorToolbar';
import s from './WorldEditorPersonality.module.scss';
import { LoadScreen } from './LoadScreen/LoadScreen';

export const WorldEditorPersonality = observer(function WorldEditorPersonality() {
const gameViewRef = React.useRef<HTMLDivElement>();
Expand Down Expand Up @@ -37,6 +38,10 @@ export const WorldEditorPersonality = observer(function WorldEditorPersonality()
>
<GameView />
</div>

{!WorldEditorState.ready && (
<LoadScreen />
)}
</div>
);
});
Expand Up @@ -5,6 +5,8 @@ import { sendApiMessage } from "utils/api";
import { InputController } from "./InputController";
import { onWindowEvent } from 'utils/windowMessages';
import { ShellPersonality, ShellState } from 'store/ShellState';
import { FilesystemEntry } from 'shared/api.types';
import { FXWORLD_FILE_EXT } from 'assets/fxworld/fxworld-types';

const noop = () => {};

Expand All @@ -23,6 +25,8 @@ export enum EditorMode {
export const WorldEditorState = new class WorldEditorState {
private inputController: InputController;

public ready = false;

public editorSelect = false;
public editorMode = EditorMode.TRANSLATE;
public editorLocal = false;
Expand All @@ -31,25 +35,39 @@ export const WorldEditorState = new class WorldEditorState {

public selection: WESelection = null;

private mapEntry: FilesystemEntry | null = null;

constructor() {
makeAutoObservable(this);

onWindowEvent('we:selection', (selection: WESelection) => runInAction(() => {
this.selection = selection;
}));

onWindowEvent('we:ready', () => {
this.ready = true;
});
}

get mapName(): string {
if (this.mapEntry) {
return this.mapEntry.name.replace(FXWORLD_FILE_EXT, '');
}

return '';
}

public mapFile: string = 'C:\\dev\\test\\ves\\candy-land.fxworld';
openMap = (mapFile: string) => {
this.mapFile = mapFile;
openMap = (entry: FilesystemEntry) => {
this.mapEntry = entry;

sendApiMessage(worldEditorApi.start);

ShellState.setPersonality(ShellPersonality.WORLD_EDITOR);
};

closeMap = () => {
this.mapFile = '';
this.ready = false;
this.mapEntry = null;

sendApiMessage(worldEditorApi.stop);

Expand Down
@@ -0,0 +1,84 @@
@import "../../vars.scss";

.root {
display: flex;
align-items: center;
justify-content: center;

height: 100%;

gap: $q;

.mode {
flex-grow: 0;

position: relative;

display: flex;
align-items: center;
justify-content: center;

gap: $q*2;

user-select: none;

height: $weToolbarHeight;

padding: 0 $q*5;

@include backdrop-blur;

font-weight: 100;
letter-spacing: 1px;

svg {
font-size: $fs2;
}

span {
position: absolute;

top: $weToolbarHeight + $q;

display: flex;
align-items: center;
justify-content: center;

padding: $q $q*2;

color: $fgColor;
background-color: rgba($bgColor, .75);
backdrop-filter: blur(20px);
border-radius: 2px;

@include fontPrimary;
font-size: $fs08;
font-weight: 100;
text-transform: lowercase;

opacity: 0;
pointer-events: none;

z-index: 1;

:global(.shortcut) {
margin-left: $q;
}
}

@include interactiveTransition;

&:hover {
background-color: $acColor;

span {
opacity: 1;
}
}

&.active {
background-color: mix(rgba($acColor, .6), rgba($fgColor, .2));
box-shadow: 0 -2px 0 0 $acColor inset;
}
}
}
@@ -0,0 +1,54 @@
import React from 'react';
import classnames from 'classnames';
import { observer } from 'mobx-react-lite';
import { CgArrowsExpandUpRight } from 'react-icons/cg';
import { EditorMode, WorldEditorState } from 'personalities/WorldEditorPersonality/WorldEditorState';
import { AiOutlineRotateRight } from 'react-icons/ai';
import { GiResize } from 'react-icons/gi';
import { BsBoundingBoxCircles } from 'react-icons/bs';
import s from './ModeSelector.module.scss';

export const ModeSelector = observer(function ModeSelector() {
return (
<div className={s.root}>
<div
onClick={WorldEditorState.enableTranslation}
className={classnames(s.mode, { [s.active]: WorldEditorState.editorMode === EditorMode.TRANSLATE })}
>
<CgArrowsExpandUpRight />
<span>
Translate
<div className="shortcut">1</div>
</span>
</div>
<div
onClick={WorldEditorState.enableRotation}
className={classnames(s.mode, { [s.active]: WorldEditorState.editorMode === EditorMode.ROTATE })}
>
<AiOutlineRotateRight />
<span>
Rotate
<div className="shortcut">2</div>
</span>
</div>
<div
onClick={WorldEditorState.enableScaling}
className={classnames(s.mode, { [s.active]: WorldEditorState.editorMode === EditorMode.SCALE })}
>
<GiResize />
<span>
Scale
<div className="shortcut">3</div>
</span>
</div>

<div className={classnames(s.mode, { [s.active]: WorldEditorState.editorLocal })} onClick={WorldEditorState.toggleEditorLocal}>
<BsBoundingBoxCircles />
<span>
Local coords
<div className="shortcut">~</div>
</span>
</div>
</div>
);
});
Expand Up @@ -15,25 +15,39 @@
user-select: none;

@include interactiveTransition;
@include backdrop-blur;

color: $fgColor;

&:hover {
background-color: $acColor;
}

svg {
font-size: $fs4;
}
}

.browser {
position: absolute;

top: $weToolbarHeight;
right: 0;
top: 0;
left: 0;

display: flex;
flex-direction: column;

width: 15vw;
height: calc(100vh - #{$weToolbarHeight});
// height: calc(100vh - #{$weToolbarHeight});
height: 100vh;

@include overlayPanel;
backdrop-filter: blur(20px);

background-color: rgba($bgColor, .95);
// box-shadow: 0 0 0 2px rgba($fgColor, .5) inset;
border: solid 2px rgba($fgColor, .25);

z-index: 4000;

.loader {
display: flex;
Expand All @@ -46,7 +60,8 @@
}

.filter {
margin: $q;
// margin: $q;
height: auto;
}

.list {
Expand All @@ -55,10 +70,13 @@
.item {
width: 100%;

padding: $q*2;
padding: $q*2 $q*4;

cursor: default;

font-size: $fs08;
font-weight: 100;

@include ellipsis;

&.active {
Expand Down
Expand Up @@ -173,7 +173,7 @@ const ObjectsBrowserDropdown = React.memo(function ObjectsBrowserDropdown() {
width={width}
height={height}
itemCount={activeSet.length}
itemSize={32}
itemSize={28}
>
{({ index, style }) => {
const name = activeSet[index];
Expand Down
Expand Up @@ -8,23 +8,28 @@
width: $weToolbarHeight;
height: $weToolbarHeight;

color: rgba($fgColor, .75);
color: $fgColor;

cursor: pointer;
user-select: none;

@include interactiveTransition;
@include backdrop-blur;

&:hover {
background-color: rgba($acColor, 1);
}

svg {
font-size: $fs2;
}
}

.status-center {
position: fixed;

top: $weToolbarHeight;
right: 0;
right: $weToolbarHeight;

width: 25vw;
min-height: $q*10;
Expand All @@ -36,7 +41,7 @@
position: fixed;

top: calc(#{$weToolbarHeight} - 11px);
right: $weToolbarHeight/3.25;
right: $weToolbarHeight + $weToolbarHeight/3.25;

color: rgba($fgColor, .5);

Expand Down
Expand Up @@ -12,6 +12,8 @@

height: $weToolbarHeight;

z-index: 1;

> div {
width: 33%;
height: 100%;
Expand Down Expand Up @@ -40,6 +42,8 @@

height: 100%;

@include backdrop-blur;

.name {
flex-grow: 1;

Expand All @@ -49,119 +53,39 @@

height: 100%;

padding: $q*2.5;

background-color: rgba($fgColor, .1);

svg {
margin-right: $q;
}
}

.close {
display: flex;
align-items: center;
justify-content: center;

width: $weToolbarHeight;
height: 100%;
padding: $q*2;
margin-right: $q;

color: $fgColor;
background: transparent;
border: none;
// background-color: rgba($fgColor, .1);

svg {
font-size: $fs2;
}

@include interactiveTransition;

&:hover {
background-color: $acColor;
margin-right: $q*2;
}
}
}

.modes {
.close {
display: flex;
align-items: center;
justify-content: center;

width: $weToolbarHeight;
height: 100%;

gap: 1px;

.mode {
flex-grow: 0;

position: relative;

display: flex;
align-items: center;
justify-content: center;

gap: $q*2;

user-select: none;

height: $weToolbarHeight;

padding: 0 $q*5;

background-color: rgba($fgColor, .2);

font-weight: 100;
letter-spacing: 1px;

svg {
font-size: $fs2;
}

span {
position: absolute;

top: $weToolbarHeight + $q;
color: $fgColor;
border: none;

display: flex;
align-items: center;
justify-content: center;
cursor: pointer;

padding: $q $q*2;

color: $fgColor;
background-color: rgba($bgColor, .75);
backdrop-filter: blur(20px);
border-radius: 2px;

@include fontPrimary;
font-size: $fs08;
font-weight: 100;
text-transform: lowercase;

opacity: 0;
pointer-events: none;

z-index: 1;

:global(.shortcut) {
margin-left: $q;
}
}

@include interactiveTransition;
svg {
font-size: $fs2;
}

&:hover {
background-color: $acColor;
@include interactiveTransition;
@include backdrop-blur;

span {
opacity: 1;
}
}

&.active {
background-color: mix(rgba($acColor, .6), rgba($fgColor, .2));
box-shadow: 0 -2px 0 0 $acColor inset;
}
&:hover {
background-color: $acColor;
}
}
}
@@ -1,14 +1,11 @@
import React from 'react';
import classnames from 'classnames';
import { observer } from "mobx-react-lite";
import { EditorMode, WorldEditorState } from '../WorldEditorState';
import { BsBoundingBoxCircles, BsX } from 'react-icons/bs';
import { CgArrowsExpandUpRight } from 'react-icons/cg';
import { GiResize } from 'react-icons/gi';
import { AiOutlineRotateRight } from 'react-icons/ai';
import { WorldEditorState } from '../WorldEditorState';
import { BsX } from 'react-icons/bs';
import { BiWorld } from 'react-icons/bi';
import { StatusBar } from './StatusBar/StatusBar';
import { ObjectsBrowser } from './ObjectsBrowser/ObjectsBrowser';
import { ModeSelector } from './ModeSelector/ModeSelector';
import s from './WorldEditorToolbar.module.scss';

// import { serverApi } from 'shared/api.events';
Expand All @@ -17,72 +14,38 @@ import s from './WorldEditorToolbar.module.scss';
export const WorldEditorToolbar = observer(function WorldEditorToolbar() {
return (
<div className={s.root}>
<div>
<div className={s.left}>
<div className={s.header}>
<button
className={s.close}
onClick={WorldEditorState.closeMap}
>
<BsX />
</button>
<div className={s.name}>
<BiWorld />
{WorldEditorState.mapFile}
{WorldEditorState.mapName}
</div>
</div>

{WorldEditorState.ready && (
<ObjectsBrowser />
)}

{/* <button onClick={() => sendApiMessage(serverApi.restartResource, 'sdk-game')}>
restart sdk-game
</button> */}
</div>

<div>
<div className={s.modes}>
<div
onClick={WorldEditorState.enableTranslation}
className={classnames(s.mode, { [s.active]: WorldEditorState.editorMode === EditorMode.TRANSLATE })}
>
<CgArrowsExpandUpRight />
<span>
Translate
<div className="shortcut">1</div>
</span>
</div>
<div
onClick={WorldEditorState.enableRotation}
className={classnames(s.mode, { [s.active]: WorldEditorState.editorMode === EditorMode.ROTATE })}
>
<AiOutlineRotateRight />
<span>
Rotate
<div className="shortcut">2</div>
</span>
</div>
<div
onClick={WorldEditorState.enableScaling}
className={classnames(s.mode, { [s.active]: WorldEditorState.editorMode === EditorMode.SCALE })}
>
<GiResize />
<span>
Scale
<div className="shortcut">3</div>
</span>
</div>

<div className={classnames(s.mode, { [s.active]: WorldEditorState.editorLocal })} onClick={WorldEditorState.toggleEditorLocal}>
<BsBoundingBoxCircles />
<span>
Local coords
<div className="shortcut">~</div>
</span>
</div>
</div>
<div className={s.center}>
{WorldEditorState.ready && (
<ModeSelector />
)}
</div>

<div>
<ObjectsBrowser />

<div className={s.right}>
<StatusBar />

<button
className={s.close}
onClick={WorldEditorState.closeMap}
>
<BsX />
</button>
</div>
</div>
);
Expand Down
Expand Up @@ -3,16 +3,19 @@
$zGameView: 1;
$zToolbar: 2;

$editorToolbarHeight: $q*10;

$weToolbarHeight: $q*10;

@mixin overlayPanel {
backdrop-filter: blur(20px);

background-color: rgba($bgColor, .95);
box-shadow: 0 0 10px 5px rgba($bgColor, .5), 0 0 30px 10px rgba($bgColor, .25);
box-shadow: 0 0 10px 0 rgba($bgColor, .5);
border: solid 2px rgba($fgColor, .5);

z-index: 4000;
}

@mixin backdrop-blur {
backdrop-filter: blur(20px);
background-color: rgba($bgColor, .25);
}
1 change: 1 addition & 0 deletions ext/sdk/resources/sdk-root/shell/src/shared/api.events.ts
Expand Up @@ -134,6 +134,7 @@ export const outputApi = {

export const gameApi = {
ack: 'game:ack',
gameState: 'game:gameState',
gameLaunched: 'game:gameLaunched',
gameProcessStateChanged: 'game:gameStateChanged',
connectionStateChanged: 'game:connectionStateChanged',
Expand Down
Expand Up @@ -14,11 +14,14 @@ export enum InitFunctionType {
}

export const GameLoadingState = new class GameLoadingState {
public loadingProgress = 0;

constructor() {
makeAutoObservable(this);

onLoadingEvent('loadProgress', ({ loadFraction }) => {
log('loadProgress', loadFraction);
this.loadingProgress = loadFraction;
});

onLoadingEvent('startInitFunction', ({ type }) => {
Expand Down Expand Up @@ -72,4 +75,8 @@ export const GameLoadingState = new class GameLoadingState {
log('onLogLine', message);
});
}

resetLoadingProgress() {
this.loadingProgress = 0;
}
}
25 changes: 22 additions & 3 deletions ext/sdk/resources/sdk-root/shell/src/store/GameState.ts
@@ -1,18 +1,22 @@
import { GameStates } from "backend/game/game-contants";
import { makeAutoObservable, runInAction } from "mobx";
import { gameApi } from "shared/api.events";
import { NetLibraryConnectionState, SDKGameProcessState } from "shared/native.enums";
import { onApiMessage, sendApiMessage } from "utils/api";
import { GameLoadingState } from "./GameLoadingState";

export const GameState = new class GameState {
constructor() {
makeAutoObservable(this);

onApiMessage(gameApi.ack, (data) => runInAction(() => {
this.state = data.gameState;
this.launched = data.gameLaunched;
this.processState = data.gameProcessState;
this.connectionState = data.connectionState;
}));

onApiMessage(gameApi.gameState, this.setState);
onApiMessage(gameApi.gameLaunched, this.setLaunched);
onApiMessage(gameApi.gameProcessStateChanged, this.setProcessState);
onApiMessage(gameApi.connectionStateChanged, this.setConnectionState);
Expand All @@ -22,12 +26,27 @@ export const GameState = new class GameState {
sendApiMessage(gameApi.ack);
}

public state = GameStates.NOT_RUNNING;
private setState = (state) => {
this.state = state;

if (this.state === GameStates.LOADING) {
GameLoadingState.resetLoadingProgress();
}
};

public launched = false;
private setLaunched = (launched) => this.launched = launched;
private setLaunched = (launched) => {
this.launched = launched;
};

public processState = SDKGameProcessState.GP_STOPPED;
private setProcessState = ({ current }) => this.processState = current;
private setProcessState = ({ current }) => {
this.processState = current;
};

public connectionState = NetLibraryConnectionState.CS_IDLE;
private setConnectionState = ({ current }) => this.connectionState = current;
private setConnectionState = ({ current }) => {
this.connectionState = current;
}
};