Vue Modules - Full-Page Screens
Vue Modules are full-page screens like the career menu, garage, vehicle config, and bigmap. They use Vue Router for navigation and can contain complex multi-view layouts.
Vue Modules are full-page screens like the career menu, garage, vehicle config, and bigmap. They use Vue Router for navigation and can contain complex multi-view layouts.
Overview
Vue Modules are:
- Full-screen views - replace the game view or overlay it
- Route-based - accessed via Vue Router paths
- Feature-rich - contain sub-views, state management, complex UI
- Menu-integrated - shown when entering specific game states
Creating a Vue Module
1. Create Module Folder Structure
modules/
└── myModule/
├── routes.js # Route definitions (required)
├── views/
│ ├── Main.vue # Main view component
│ └── Detail.vue # Sub-view component
├── components/
│ └── MyWidget.vue # Module-specific components
└── stores/
└── myStore.js # Pinia stores (optional)2. Define Routes
// modules/myModule/routes.js
import MainView from './views/Main.vue'
import DetailView from './views/Detail.vue'
export default [
{
path: '/myModule',
name: 'myModule',
component: MainView,
meta: {
// Hide HUD apps while in this screen
uiApps: {
shown: false
},
// Show info bar at bottom
infoBar: {
visible: true,
showSysInfo: false
},
// Show top navigation bar
topBar: {
visible: true
}
}
},
{
path: '/myModule/detail/:id',
name: 'myModule.detail',
component: DetailView,
props: true, // Pass route params as props
meta: {
uiApps: { shown: false }
}
}
]Routes are auto-discovered via glob import in router/index.js:
// router/index.js
routes: [
...Object.values(
import.meta.glob('@/modules/*/routes.js', { eager: true, import: 'default' })
).flatMap(routes => routes || [])
]3. Create View Components
<!-- modules/myModule/views/Main.vue -->
<template>
<div class="my-module-view">
<BngScreenHeading>
<template #title>{{ $t('ui.myModule.title') }}</template>
</BngScreenHeading>
<div class="content">
<BngCard v-for="item in items" :key="item.id">
<h3>{{ item.name }}</h3>
<BngButton @click="viewDetail(item.id)">
{{ $t('ui.common.viewDetails') }}
</BngButton>
</BngCard>
</div>
<div class="actions">
<BngButton @click="goBack" :accent="ACCENTS.secondary">
{{ $t('ui.common.back') }}
</BngButton>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useBridge } from '@/bridge'
import { BngScreenHeading, BngCard, BngButton, ACCENTS } from '@/common/components/base'
const router = useRouter()
const { lua } = useBridge()
const items = ref([])
onMounted(async () => {
// Fetch data from Lua
const data = await lua.myMod_myModule.getItems()
items.value = data || []
})
function viewDetail(id) {
router.push({ name: 'myModule.detail', params: { id } })
}
function goBack() {
router.back()
}
</script>
<style lang="scss" scoped>
.my-module-view {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
padding: 2em;
color: white;
.content {
flex: 1;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1em;
}
.actions {
margin-top: 1em;
display: flex;
gap: 0.5em;
}
}
</style>Real Examples
Career Routes (Complex Module)
// modules/career/routes.js
import Pause from './views/Pause.vue'
import Computer from './views/ComputerMain.vue'
import VehicleInventory from './views/VehicleInventoryMain.vue'
import Tuning from './views/TuningMain.vue'
import Painting from './views/PaintingMain.vue'
export default [
// Career Pause Menu
{
path: '/menu.careerPause',
name: 'menu.careerPause',
component: Pause,
props: true,
meta: {
clickThrough: true, // Allow clicking through to game
infoBar: {
withAngular: true,
visible: true,
showSysInfo: true
},
uiApps: { shown: false },
topBar: { visible: true }
}
},
// Nested career routes
{
path: '/career',
children: [
{
path: 'computer',
name: 'computer',
component: Computer,
meta: { uiApps: { shown: false } }
},
{
path: 'vehicleInventory',
name: 'vehicleInventory',
component: VehicleInventory
},
{
path: 'tuning',
name: 'tuning',
component: Tuning
},
{
path: 'painting',
name: 'painting',
component: Painting
}
]
}
]Garage View (Complex Component)
<!-- modules/garage/views/Garage.vue -->
<template>
<div v-if="loaded.init" class="garage-view">
<!-- Header with breadcrumb -->
<div class="garage-row-title">
<h2>{{ vehcomp ? $t('ui.garage.tabs.' + vehcomp) : vehicle.name }}</h2>
</div>
<!-- Main menu when no sub-view -->
<div v-if="!vehcomp" class="garage-menu">
<GarageButton
v-for="tab in menuTabs"
:key="tab.id"
:icon="tab.icon"
:active="vehcomp === tab.id"
@click="menuOpen(tab.id)"
v-bng-disabled="!loaded.vehicle"
>
{{ $t(tab.label) }}
</GarageButton>
</div>
<!-- Dynamic sub-view -->
<component
v-if="vehcomp && vehcompview"
:is="vehcompview"
with-background
/>
</div>
</template>
<script setup>
import { ref, reactive, markRaw, onBeforeMount, onUnmounted } from 'vue'
import { useBridge } from '@/bridge'
import { useEvents, useStreams } from '@/services/events'
import { icons } from '@/common/components/base'
// Sub-components for dynamic loading
import Paint from '@/modules/vehicleConfig/components/Paint.vue'
import Parts from '@/modules/vehicleConfig/components/Parts.vue'
import Tuning from '@/modules/vehicleConfig/components/Tuning.vue'
const components = { paint: Paint, parts: Parts, tuning: Tuning }
const { lua, api } = useBridge()
const events = useEvents()
// State
const loaded = reactive({ init: false, vehicle: false })
const vehicle = reactive({ name: 'Unknown' })
const vehcomp = ref('')
const vehcompview = ref(null)
// Menu configuration
const menuTabs = [
{ id: 'parts', icon: icons.engine, label: 'ui.garage.tabs.parts' },
{ id: 'tuning', icon: icons.wrench, label: 'ui.garage.tabs.tune' },
{ id: 'paint', icon: icons.sprayCan, label: 'ui.garage.tabs.paint' }
]
// Stream subscription for vehicle electrics
useStreams(['electrics'], (streams) => {
if (streams.electrics) {
// Update vehicle electric state
}
})
// Menu navigation
async function menuOpen(mode) {
vehcomp.value = vehcomp.value === mode ? '' : mode
if (components[mode]) {
lua.extensions.gameplay_garageMode.setGarageMenuState(mode)
vehcompview.value = markRaw(components[mode])
} else {
vehcomp.value = ''
vehcompview.value = null
}
}
// Vehicle change handler
async function onVehicleChange() {
loaded.vehicle = false
const data = await lua.core_vehicles.getCurrentVehicleDetails()
if (!data) return
loaded.vehicle = true
vehicle.name = data.model.Brand
? `${data.model.Brand} ${data.model.Name}`
: data.configs.Name
}
onBeforeMount(async () => {
events.on('VehicleChange', onVehicleChange)
api.activeObjectLua('electrics.setIgnitionLevel(1)')
await onVehicleChange()
loaded.init = true
})
onUnmounted(() => {
// events auto-cleanup handled by useEvents()
})
</script>Route Meta Options
uiApps
meta: {
uiApps: {
shown: false, // Hide all HUD apps
layout: 'tasklist' // Use specific layout
}
}infoBar
meta: {
infoBar: {
visible: true, // Show bottom info bar
showSysInfo: true, // Show FPS, ping, etc.
withAngular: false, // Angular compatibility mode
hints: [ // Action hints
{ action: 'menu', label: 'ui.common.back' }
]
}
}topBar
meta: {
topBar: {
visible: true // Show top navigation bar
}
}Other Options
meta: {
clickThrough: true // Allow clicks to pass through to game
}Navigation Patterns
Using Vue Router
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
// Navigate to route
router.push({ name: 'myModule.detail', params: { id: '123' } })
// Navigate with query
router.push({ name: 'myModule', query: { filter: 'active' } })
// Go back
router.back()
// Access current route
const currentId = route.params.idUsing bngVue (Legacy/Angular Bridge)
const bngVue = window.bngVue
// Navigate to Angular state
bngVue.gotoAngularState('menu.mainmenu')
// Navigate to Vue state
bngVue.gotoGameState('garagemode')
// Navigate with params
bngVue.gotoGameState('menu.vehicles', {
params: { mode: 'garageMode', garage: 'all' }
})Navigation Events from Lua
-- Navigate to Vue route
guihooks.trigger('ChangeState', {state = 'menu.careerPause'})
-- Navigate with params
guihooks.trigger('ChangeState', {
state = 'career/vehicleInventory',
params = {}
})State Management
Using Pinia Stores
// stores/myStore.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useMyStore = defineStore('myStore', () => {
// State
const items = ref([])
const loading = ref(false)
// Getters
const itemCount = computed(() => items.value.length)
// Actions
async function fetchItems() {
loading.value = true
try {
const { lua } = useBridge()
items.value = await lua.myExtension.getItems()
} finally {
loading.value = false
}
}
return { items, loading, itemCount, fetchItems }
})Using Stores in Components
<script setup>
import { storeToRefs } from 'pinia'
import { useMyStore } from '../stores/myStore'
const store = useMyStore()
const { items, loading } = storeToRefs(store)
// Call actions
store.fetchItems()
</script>Scoped Navigation (Controller Support)
For gamepad/controller navigation:
<template>
<div
class="my-view"
v-bng-scoped-nav="{
activateOnMount: true,
bubbleWhitelistEvents: ['menu']
}"
@deactivate="onExit"
>
<BngButton
:bng-scoped-nav-autofocus="true"
@click="doSomething"
>
First Button (auto-focused)
</BngButton>
<BngButton @click="doSomethingElse">
Second Button
</BngButton>
</div>
</template>
<script setup>
import { vBngScopedNav, vBngOnUiNav } from '@/common/directives'
function onExit() {
// Handle scope deactivation (e.g., back button)
}
</script>Common Patterns
Loading State
<template>
<div v-if="!loaded" class="loading">
<BngSpinner />
<span>{{ $t('ui.common.loading') }}</span>
</div>
<div v-else class="content">
<!-- Content here -->
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const loaded = ref(false)
onMounted(async () => {
await fetchData()
loaded.value = true
})
</script>Error Handling
<script setup>
import { ref } from 'vue'
import { openMessage } from '@/services/popup'
const error = ref(null)
async function saveData() {
try {
await lua.myExtension.save(data.value)
} catch (e) {
error.value = e.message
await openMessage(
$translate.instant('ui.common.error'),
e.message
)
}
}
</script>Confirmation Dialogs
<script setup>
import { openConfirmation } from '@/services/popup'
import { $translate } from '@/services'
async function deleteItem() {
const confirmed = await openConfirmation(
$translate.instant('ui.common.confirm'),
$translate.instant('ui.myModule.deleteConfirm'),
[
{ label: $translate.instant('ui.common.yes'), value: true },
{ label: $translate.instant('ui.common.no'), value: false }
]
)
if (confirmed) {
await lua.myExtension.deleteItem(itemId)
}
}
</script>Best Practices
- Lazy load heavy components - Use
markRaw()for dynamic imports - Prefer composition API - Use
<script setup>syntax - Use stores for shared state - Avoid prop drilling
- Handle loading states - Always show feedback
- Clean up on unmount - Remove listeners, cancel requests
- Use translation keys - Never hardcode strings
- Test controller navigation - Ensure gamepad support works
See Also
- UI Apps - HUD widgets
- Services - Popup, translation, navigation
- Components - Reusable Bng* components
Services - Shared Utilities
Services provide shared functionality across the UI: event handling, popups, translations, storage, and navigation.
BeamNG ImGui API Reference
All functions via `local im = ui_imgui`. Types: `BoolPtr`, `IntPtr`, `FloatPtr` are `{[0]=value}` tables. `ArrayChar` is a string buffer. `ImVec2(x,y)` and `ImVec4(x,y,z,w)` are engine structs.