This commit is contained in:
Tanner Sommers 2024-06-09 22:43:57 -05:00
parent f16a32b63e
commit 1259e726bb
17 changed files with 4611 additions and 368 deletions

220
README.md
View File

@ -1,216 +1,24 @@
<div align="center">
<img href="https://projecterror.dev" width="150" src="https://i.tasoagc.dev/c1pD" alt="Material-UI logo" />
</div>
<h1 align="center">FiveM React and Lua Boilerplate</h1>
# Sticks Keypad
<div align="center">
A simple and extendable React (TypeScript) boilerplate designed around the Lua ScRT
</div>
A simple script to have a keypad inside of your city.
<div align="center">
## Installation
[![license](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/project-error/pe-utils/master/LICENSE)
![Discord](https://img.shields.io/discord/791854454760013827?label=Our%20Discord)
![David](https://img.shields.io/david/project-error/fivem-react-boilerplate-lua)
[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=project-error/fivem-react-boilerplate-lua)](https://dependabot.com)
</div>
1. Download the latest version of the script using the "Download ZIP" button on the right side of this page.
2. Extract the ZIP file.
3. Rename the folder to `sticks-keypad` and copy it to your `resources` directory.
4. Add `ensure sticks-keypad` to your `server.cfg`.
5. All done, start your server and enjoy!
This repository is a basic boilerplate for getting started
with React in NUI. It contains several helpful utilities and
is bootstrapped using `create-react-app`. It is for both browser
and in-game based development workflows.
## Configuration
For in-game workflows, Utilizing `craco` to override CRA, we can have hot
builds that just require a resource restart instead of a full
production build
You can configure the script in the `server/server.lua` file. To add a new entry, you need the door ID, the cords of the door, the teleport cords, and the password. An example is already in the file to help you understand how to add a new entry, and can also be seen below.
This version of the boilerplate is meant for the CfxLua runtime.
## Requirements
* [Node > v10.6](https://nodejs.org/en/)
* [Yarn](https://yarnpkg.com/getting-started/install) (Preferred but not required)
*A basic understanding of the modern web development workflow. If you don't
know this yet, React might not be for you just yet.*
## Getting Started
First clone the repository or use the template option and place
it within your `resources` folder
### Installation
*The boilerplate was made using `yarn` but is still compatible with
`npm`.*
Install dependencies by navigating to the `web` folder within
a terminal of your choice and type `npm i` or `yarn`.
## Features
This boilerplate comes with some utilities and examples to work off of.
### Lua Utils
**SendReactMessage**
This is a small wrapper for dispatching NUI messages. This is designed
to be used with the `useNuiEvent` React hook.
Signature
```lua
---@param action string The action you wish to target
---@param data any The data you wish to send along with this action
SendReactMessage(action, data)
-- Example
{1, {x = -3029.3825683594, y = 72.813552856445, z = 11.4}, {x = -3031.3232421875, y = 93.021644592285, z = 12.346099853516}, "1234"},
```
Usage
```lua
SendReactMessage('setVisible', true)
```
## Credits
**debugPrint**
A debug printing utility that is dependent on a convar,
if the convar is set this will print out to the console.
The convar is dependent on the name given to the resource.
It follows this format `YOUR_RESOURCE_NAME-debugMode`
To turn on debugMode add `setr YOUR_RESOURCE_NAME-debugMode 1` to
your server.cfg or use the `setr` console command instead.
Signature (Replicates `print`)
```lua
---@param ... any[] The arguments you wish to send
debugPrint(...)
```
Usage
```lua
debugPrint('wow cool string to print', true, someOtherVar)
```
### React Utils
Signatures are not included for these utilities as the type definitions
are sufficient enough.
**useNuiEvent**
This is a custom React hook that is designed to intercept and handle
messages dispatched by the game scripts. This is the primary
way of creating passive listeners.
*Note: For now handlers can only be registered a single time. I haven't
come across a personal usecase for a cascading event system*
**Usage**
```jsx
const MyComp: React.FC = () => {
const [state, setState] = useState('')
useNuiEvent<string>('myAction', (data) => {
// the first argument to the handler function
// is the data argument sent using SendReactMessage
// do whatever logic u want here
setState(data)
})
return(
<div>
<h1>Some component</h1>
<p>{state}</p>
</div>
)
}
```
**fetchNui**
This is a simple NUI focused wrapper around the standard `fetch` API.
This is the main way to accomplish active NUI data fetching
or to trigger NUI callbacks in the game scripts.
When using this, you must always at least callback using `{}`
in the gamescripts.
*This can be heavily customized to your use case*
**Usage**
```ts
// First argument is the callback event name.
fetchNui<ReturnData>('getClientData').then(retData => {
console.log('Got return data from client scripts:')
console.dir(retData)
setClientData(retData)
}).catch(e => {
console.error('Setting mock data due to error', e)
setClientData({ x: 500, y: 300, z: 200})
})
```
**debugData**
This is a function allowing for mocking dispatched game script
actions in a browser environment. It will trigger `useNuiEvent` handlers
as if they were dispatched by the game scripts. **It will only fire if the current
environment is a regular browser and not CEF**
**Usage**
```ts
// This will target the useNuiEvent hooks registered with `setVisible`
// and pass them the data of `true`
debugData([
{
action: 'setVisible',
data: true,
}
])
```
**Misc Utils**
These are small but useful included utilities.
* `isEnvBrowser()` - Will return a boolean indicating if the current
environment is a regular browser. (Useful for logic in development)
## Development Workflow
This boilerplate was designed with development workflow in mind.
It includes some helpful scripts to accomplish that.
**Hot Builds In-Game**
When developing in-game, you can use the hot build system by
running the `start:game` script. This is essentially the start
script but it writes to disk. Meaning all that is required is a
resource restart to update the game script
**Usage**
```sh
# yarn
yarn start:game
# npm
npm run start:game
```
**Production Builds**
When you are done with development phase for your resource. You
must create a production build that is optimized and minimized.
You can do this by running the following:
```sh
npm run build
yarn build
```
## Additional Notes
Need further support? Join our [Discord](https://discord.com/invite/HYwBjTbAY5)!
This script used a boilerplate from [NPWD](https://github.com/project-error/fivem-react-boilerplate-lua). Thank you for the boilerplate!

View File

@ -3,23 +3,93 @@ local function toggleNuiFrame(shouldShow)
SendReactMessage('setVisible', shouldShow)
end
RegisterCommand('show-nui', function()
toggleNuiFrame(true)
debugPrint('Show NUI frame')
function Draw3DText(x, y, z, text)
local onScreen, _x, _y = World3dToScreen2d(x, y, z)
local px,py,pz=table.unpack(GetGameplayCamCoords())
if onScreen then
SetTextScale(0.35, 0.35)
SetTextFont(4)
SetTextProportional(1)
SetTextColour(255, 255, 255, 215)
SetTextDropShadow(0, 0, 0, 55)
SetTextEdge(0, 0, 0, 150)
SetTextDropShadow()
SetTextOutline()
SetTextEntry("STRING")
SetTextCentre(1)
AddTextComponentString(text)
DrawText(_x,_y)
end
end
function dump(o)
if type(o) == 'table' then
local s = '{ '
for k,v in pairs(o) do
if type(k) ~= 'number' then k = '"'..k..'"' end
s = s .. '['..k..'] = ' .. dump(v) .. ','
end
return s .. '} '
else
return tostring(o)
end
end
-- When we start up, request the doors from the server
local clientDoors = {}
RegisterNetEvent('sticks_keypad:setClientDoors')
AddEventHandler('sticks_keypad:setClientDoors', function(doors)
clientDoors = doors
-- For each door, create a marker and a text label to allow the user to press "E" to open the keypad
for i, v in ipairs(clientDoors) do
-- Dump the door data
print('[sticks_keypad] Loaded door id: ' .. v[1] .. ' with location vector: ' .. dump(v[2]))
-- Create a draw thread
Citizen.CreateThread(function()
while true do
Citizen.Wait(0)
local playerPed = PlayerPedId()
local playerCoords = GetEntityCoords(playerPed)
local doorCoords = vector3(v[2].x, v[2].y, v[2].z)
local distance = #(playerCoords - doorCoords)
if distance < 2.0 then
DrawMarker(1, v[2].x, v[2].y, v[2].z + 0.5, 0, 0, 0, 0, 0, 0, 0.5, 0.5, 0.5, 255, 0, 0, 200, 0, 0, 0, 0)
Draw3DText(v[2].x, v[2].y, v[2].z + 0.5, "Press ~g~E~w~ to open the keypad")
if IsControlJustPressed(0, 38) then
toggleNuiFrame(true)
SendReactMessage('sticks_keypad:uiInit', {doorId = v[1], code = v[4]})
end
end
end
end)
end
end)
RegisterNUICallback('hideFrame', function(_, cb)
-- Handle the NUI message from the React app
RegisterNUICallback('sticks_keypad:codeSubmitSuccess', function(data, cb)
toggleNuiFrame(false)
debugPrint('Hide NUI frame')
cb({})
dump(data)
TriggerServerEvent('sticks_keypad:checkCodeAndTeleport', data.doorId, data.code)
cb({ok = true})
end)
RegisterNUICallback('getClientData', function(data, cb)
debugPrint('Data sent by React', json.encode(data))
-- Hide Frame NUICallback
RegisterNUICallback('sticks_keypad:hideFrame', function(data, cb)
toggleNuiFrame(false)
cb({ok = true})
end)
-- Lets send back client coords to the React frame for use
local curCoords = GetEntityCoords(PlayerPedId())
-- Command to print out the current vector3 position of the player
RegisterCommand('getpos', function()
local playerPed = PlayerPedId()
local playerCoords = GetEntityCoords(playerPed)
print(playerCoords.x .. ', ' .. playerCoords.y .. ', ' .. playerCoords.z)
end, false)
local retData <const> = { x = curCoords.x, y = curCoords.y, z = curCoords.z }
cb(retData)
end)
-- Send the event to the server to get the doors
TriggerServerEvent('sticks_keypad:getDoors')

View File

@ -1,9 +1,9 @@
fx_version "cerulean"
description "Basic React (TypeScript) & Lua Game Scripts Boilerplate"
author "Project Error"
description "A simple keypad script for FiveM"
author "sticksdev"
version '1.0.0'
repository 'https://github.com/project-error/fivem-react-boilerplate-lua'
repository 'https://github.com/SticksDev/sticks-keypad'
lua54 'yes'

View File

@ -0,0 +1,41 @@
-- Add doors using the fomrat
-- doors[doorId] = {loc_vector, teleport_vector, code}
print("[sticks_keypad] Loading doors...")
local doors = {
-- Example door, feel free to remove this
{1, {x = -3029.3825683594, y = 72.813552856445, z = 11.4}, {x = -3031.3232421875, y = 93.021644592285, z = 12.346099853516}, "1234"},
}
-- Print our loaded doors
for i, v in ipairs(doors) do
print("[sticks_keypad] Loaded door id: " .. v[1] .. " with code: " .. v[4] .. " and teleport vector: " .. v[2].x .. ", " .. v[2].y .. ", " .. v[2].z)
end
print("[sticks_keypad] Loaded " .. #doors .. " doors! Setting up events...")
-- Event to send the doors configuration to the client
RegisterServerEvent("sticks_keypad:getDoors")
AddEventHandler("sticks_keypad:getDoors", function()
print("[sticks_keypad::audit] Player requested doors")
TriggerClientEvent("sticks_keypad:setClientDoors", source, doors)
end)
-- Event to check if the code is correct, returns true if it is, nil if it isn't
RegisterServerEvent("sticks_keypad:checkCodeAndTeleport")
AddEventHandler("sticks_keypad:checkCodeAndTeleport", function(doorId, code)
print("[sticks_keypad::audit] Player is trying to open door id: " .. doorId .. " with code: " .. code)
local source = source
if doors[doorId][4] == code then
print("[sticks_keypad::audit] Player opened door id: " .. doorId)
-- Teleport the player to the teleport vector
SetEntityCoords(source, doors[doorId][3].x, doors[doorId][3].y, doors[doorId][3].z)
return true
else
print("[sticks_keypad::audit] Player failed to open door id: " .. doorId)
return nil
end
end)
print("[sticks_keypad] Events setup! Ready to go!")

4259
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -20,9 +20,12 @@
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"@vitejs/plugin-react": "^4.2.0",
"autoprefixer": "^10.4.19",
"eslint": "^8.54.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.4",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"typescript": "^5.2.2",
"vite": "^5.0.0"
}

6
web/postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
web/src/assets/beep.mp3 Normal file

Binary file not shown.

BIN
web/src/assets/denied.mp3 Normal file

Binary file not shown.

BIN
web/src/assets/unlock.mp3 Normal file

Binary file not shown.

View File

@ -1,26 +0,0 @@
.nui-wrapper {
text-align: center;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
pre {
counter-reset:line-numbering;
background:#2c3e50;
padding:12px 0px 14px 0;
color:#ecf0f1;
line-height:140%;
}
.popup-thing {
background: #282c34;
border-radius: 10px;
width: 500px;
height: 400px;
display: flex;
justify-content: center;
align-items: center;
color: white;
}

View File

@ -1,66 +1,153 @@
import React, { useState } from "react";
import "./App.css";
import { debugData } from "../utils/debugData";
import { fetchNui } from "../utils/fetchNui";
import React, { useState } from 'react';
import { debugData } from '../utils/debugData';
import { fetchNui } from '../utils/fetchNui';
// Import Audios
import beep from '../assets/beep.mp3';
import deined from '../assets/denied.mp3';
import unlock from '../assets/unlock.mp3';
import { useNuiEvent } from '../hooks/useNuiEvent';
// This will set the NUI to visible if we are
// developing in browser
debugData([
{
action: "setVisible",
data: true,
},
{
action: 'setVisible',
data: true,
},
]);
interface ReturnClientDataCompProps {
data: unknown;
}
const ReturnClientDataComp: React.FC<ReturnClientDataCompProps> = ({
data,
}) => (
<>
<h5>Returned Data:</h5>
<pre>
<code>{JSON.stringify(data, null)}</code>
</pre>
</>
);
interface ReturnData {
x: number;
y: number;
z: number;
}
const playKeypadSound = () => {
const audio = new Audio(beep);
audio.play();
};
const App: React.FC = () => {
const [clientData, setClientData] = useState<ReturnData | null>(null);
const [keypadState, setKeypadState] = useState<string>('');
const [code, setCode] = useState<string>('1234');
const [keypadId, setKeypadId] = useState<string>('');
const [codeValidated, setCodeValidated] = useState<boolean>(false);
const [codeState, setCodeState] = useState<boolean>(false);
const [submitButtonEnabled, setSubmitButtonEnabled] =
useState<boolean>(false);
const handleGetClientData = () => {
fetchNui<ReturnData>("getClientData")
.then((retData) => {
console.log("Got return data from client scripts:");
console.dir(retData);
setClientData(retData);
})
.catch((e) => {
console.error("Setting mock data due to error", e);
setClientData({ x: 500, y: 300, z: 200 });
});
};
// Listen for our "uiCodeSetup" event
// and set the code state
useNuiEvent<{ doorId: string; code: string }>(
'sticks_keypad:uiInit',
(data) => {
console.log('[sticks_keypad:nui::setDoorId] uiInit: init...');
setCode(data.code);
setKeypadId(data.doorId);
return (
<div className="nui-wrapper">
<div className="popup-thing">
<div>
<h1>This is the NUI Popup!</h1>
<p>Exit with the escape key</p>
<button onClick={handleGetClientData}>Get Client Data</button>
{clientData && <ReturnClientDataComp data={clientData} />}
console.log(
'[sticks_keypad:nui::setDoorId] uiInit: doorId: ',
data.doorId,
);
console.log('[sticks_keypad:nui::setDoorId] uiInit done');
},
);
async function onCodeSubmit(data: string) {
// Load both the denied and unlock audio
const deniedAudio = new Audio(deined);
const unlockAudio = new Audio(unlock);
// Check if the data is equal to the code
if (data === code) {
// Play the unlock audio
await unlockAudio.play();
// Set the code state to true
setCodeState(true);
// Send the data to the server
fetchNui('sticks_keypad:codeSubmitSuccess', {
code: data,
doorId: keypadId,
});
// Reset the keypad state
setTimeout(() => {
setKeypadState('');
setCodeState(false);
setSubmitButtonEnabled(false);
}, 1000);
} else {
// Play the denied audio
deniedAudio.play();
setCodeValidated(true);
setSubmitButtonEnabled(true);
setTimeout(() => {
setKeypadState('');
setCodeValidated(false);
setSubmitButtonEnabled(false);
}, 1000);
}
}
return (
<div className='h-screen flex justify-center items-center bg-gray-900/40'>
<div className='w-80 p-6 bg-zinc-800 rounded-lg shadow-lg'>
<div className='text-3xl text-white mb-4 text-center'>
Enter Passcode
</div>
<div className='text-2xl text-white mb-4 text-center'>
{codeState
? 'Unlocked'
: keypadState.length === code.length && codeValidated
? 'Code is incorrect'
: keypadState}
</div>
<div className='grid grid-cols-3 gap-2 mb-4'>
{Array.from({ length: 9 }).map((_, i) => (
<button
key={i}
className='w-20 h-20 bg-zinc-700 hover:bg-zinc-700/40 duration-150 text-white text-2xl rounded-lg'
onClick={() => {
if (keypadState.length < code.length) {
setKeypadState((prev) => prev + (i + 1));
playKeypadSound();
}
}}
>
{i + 1}
</button>
))}
<button
className='w-20 h-20 bg-zinc-700 hover:bg-zinc-700/40 duration-150 text-white text-2xl rounded-lg'
onClick={() => setKeypadState((prev) => prev + '0')}
>
0
</button>
<button
className='w-20 h-20 bg-zinc-700 hover:bg-zinc-700/40 duration-150 text-white text-2xl rounded-lg'
onClick={() =>
setKeypadState((prev) => prev.slice(0, -1))
}
>
DEL
</button>
<button
className='w-20 h-20 bg-zinc-700 hover:bg-zinc-700/40 duration-150 text-white text-2xl rounded-lg'
onClick={() => setKeypadState('')}
>
C
</button>
</div>
<button
className='w-full h-12 bg-green-600 hover:bg-green-700/80 duration-150 text-white text-2xl rounded-lg disabled:bg-gray-500 disabled:cursor-not-allowed'
onClick={() => onCodeSubmit(keypadState)}
disabled={
keypadState.length !== code.length ||
submitButtonEnabled
}
>
Submit
</button>
</div>
</div>
</div>
</div>
);
);
};
export default App;

View File

@ -1,18 +1,3 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
}
#root {
height: 100%
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -5,9 +5,9 @@ import App from './components/App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<VisibilityProvider>
<App />
</VisibilityProvider>
</React.StrictMode>,
<React.StrictMode>
<VisibilityProvider>
<App />
</VisibilityProvider>
</React.StrictMode>,
);

View File

@ -1,64 +1,67 @@
import React, {
Context,
createContext,
useContext,
useEffect,
useState,
} from "react";
import { useNuiEvent } from "../hooks/useNuiEvent";
import { fetchNui } from "../utils/fetchNui";
import { isEnvBrowser } from "../utils/misc";
Context,
createContext,
useContext,
useEffect,
useState,
} from 'react';
import { useNuiEvent } from '../hooks/useNuiEvent';
import { fetchNui } from '../utils/fetchNui';
import { isEnvBrowser } from '../utils/misc';
const VisibilityCtx = createContext<VisibilityProviderValue | null>(null);
interface VisibilityProviderValue {
setVisible: (visible: boolean) => void;
visible: boolean;
setVisible: (visible: boolean) => void;
visible: boolean;
}
// This should be mounted at the top level of your application, it is currently set to
// apply a CSS visibility value. If this is non-performant, this should be customized.
export const VisibilityProvider: React.FC<{ children: React.ReactNode }> = ({
children,
children,
}) => {
const [visible, setVisible] = useState(false);
const [visible, setVisible] = useState(false);
useNuiEvent<boolean>("setVisible", setVisible);
useNuiEvent<boolean>('setVisible', setVisible);
// Handle pressing escape/backspace
useEffect(() => {
// Only attach listener when we are visible
if (!visible) return;
// Handle pressing escape/backspace
useEffect(() => {
// Only attach listener when we are visible
if (!visible) return;
const keyHandler = (e: KeyboardEvent) => {
if (["Backspace", "Escape"].includes(e.code)) {
if (!isEnvBrowser()) fetchNui("hideFrame");
else setVisible(!visible);
}
};
const keyHandler = (e: KeyboardEvent) => {
if (['Backspace', 'Escape'].includes(e.code)) {
if (!isEnvBrowser()) fetchNui('sticks_keypad:hideFrame');
else setVisible(!visible);
}
};
window.addEventListener("keydown", keyHandler);
window.addEventListener('keydown', keyHandler);
return () => window.removeEventListener("keydown", keyHandler);
}, [visible]);
return () => window.removeEventListener('keydown', keyHandler);
}, [visible]);
return (
<VisibilityCtx.Provider
value={{
visible,
setVisible,
}}
>
<div
style={{ visibility: visible ? "visible" : "hidden", height: "100%" }}
>
{children}
</div>
</VisibilityCtx.Provider>
);
return (
<VisibilityCtx.Provider
value={{
visible,
setVisible,
}}
>
<div
style={{
visibility: visible ? 'visible' : 'hidden',
height: '100%',
}}
>
{children}
</div>
</VisibilityCtx.Provider>
);
};
export const useVisibility = () =>
useContext<VisibilityProviderValue>(
VisibilityCtx as Context<VisibilityProviderValue>,
);
useContext<VisibilityProviderValue>(
VisibilityCtx as Context<VisibilityProviderValue>,
);

View File

@ -32,7 +32,6 @@ export async function fetchNui<T = unknown>(
: "nui-frame-app";
const resp = await fetch(`https://${resourceName}/${eventName}`, options);
const respFormatted = await resp.json();
return respFormatted;

8
web/tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};