- Adds Character model with survival needs (hunger, thirst, energy) and state machine - Implements city:tick world ticker for offline progression every minute - Adds city territory expansion and building construction system - Introduces job economy, inventory, and transaction audit trail - Adds social graph (friends, marriage, children) and character chat - Implements admin approval workflow for new registrations - Adds hospital admission system for traffic accidents - Includes comprehensive feature tests and world seeders
376 lines
17 KiB
PHP
Executable File
376 lines
17 KiB
PHP
Executable File
@extends('layouts.app')
|
||
|
||
@section('title', $isEdit ? __('admin.stores.edit_title') : __('admin.stores.create_title'))
|
||
|
||
@section('content')
|
||
<div style="max-width: 1100px; margin: 0 auto;">
|
||
<p style="margin-top:0;"><a href="{{ route('admin.stores.index') }}">← {{ __('Back') }}</a></p>
|
||
|
||
<h1 style="color:#fff; margin:.5rem 0 1.25rem;">
|
||
🏪 {{ $isEdit ? __('admin.stores.edit_title') : __('admin.stores.create_title') }}
|
||
</h1>
|
||
|
||
<form method="POST"
|
||
action="{{ $isEdit ? route('admin.stores.update', $store) : route('admin.stores.store') }}">
|
||
@csrf
|
||
@if ($isEdit) @method('PUT') @endif
|
||
|
||
<div style="display:grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap:1.25rem; align-items:start;">
|
||
|
||
{{-- Data form --}}
|
||
<div class="card">
|
||
<div class="field">
|
||
<label for="name">{{ __('Name') }}</label>
|
||
<input id="name" type="text" name="name" value="{{ old('name', $store->name) }}" required maxlength="160">
|
||
@error('name')<div class="error">{{ $message }}</div>@enderror
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="type">{{ __('admin.stores.type') }}</label>
|
||
<input id="type" type="text" name="type" value="{{ old('type', $store->type) }}" required maxlength="60"
|
||
placeholder="bakery, market, hardware, restaurant…">
|
||
@error('type')<div class="error">{{ $message }}</div>@enderror
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="description">{{ __('admin.stores.description') }}</label>
|
||
<textarea id="description" name="description" rows="3" maxlength="2000"
|
||
style="width:100%; padding:.7rem .85rem; border-radius:.55rem; border:1px solid var(--border); background:#0b1220; color:var(--text); font-size:1rem; font-family:inherit;">{{ old('description', $store->description) }}</textarea>
|
||
@error('description')<div class="error">{{ $message }}</div>@enderror
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="owner_id">{{ __('admin.stores.owner') }}</label>
|
||
<select id="owner_id" name="owner_id">
|
||
<option value="">{{ __('admin.stores.no_owner') }}</option>
|
||
@foreach ($owners as $owner)
|
||
<option value="{{ $owner->id }}" @selected((string) old('owner_id', $store->owner_id) === (string) $owner->id)>
|
||
{{ $owner->name }} ({{ $owner->email }})
|
||
</option>
|
||
@endforeach
|
||
</select>
|
||
@error('owner_id')<div class="error">{{ $message }}</div>@enderror
|
||
</div>
|
||
|
||
<div class="field">
|
||
<label for="agent_id">{{ __('admin.stores.agent') }}</label>
|
||
<select id="agent_id" name="agent_id">
|
||
<option value="">{{ __('admin.stores.no_agent') }}</option>
|
||
@foreach ($agents as $agent)
|
||
<option value="{{ $agent->id }}" @selected((string) old('agent_id', $store->agent_id) === (string) $agent->id)>
|
||
{{ $agent->name }} ({{ $agent->type }})
|
||
</option>
|
||
@endforeach
|
||
</select>
|
||
@error('agent_id')<div class="error">{{ $message }}</div>@enderror
|
||
</div>
|
||
|
||
<div class="field" style="display:flex; align-items:center; gap:.55rem;">
|
||
<input type="checkbox" id="is_open" name="is_open" value="1"
|
||
@checked(old('is_open', $store->is_open ?? true)) style="width:1.1rem; height:1.1rem;">
|
||
<label for="is_open" style="margin:0;">{{ __('admin.stores.is_open') }}</label>
|
||
</div>
|
||
@error('is_open')<div class="error">{{ $message }}</div>@enderror
|
||
|
||
{{-- Numeric 3D coordinates (kept in sync with the picker) --}}
|
||
<fieldset style="border:1px solid var(--border); border-radius:.7rem; padding:1rem; margin:1rem 0 0;">
|
||
<legend style="padding:0 .4rem; color:var(--muted); font-size:.85rem;">{{ __('admin.stores.position') }}</legend>
|
||
|
||
<div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:.75rem;">
|
||
<div class="field" style="margin:0;">
|
||
<label for="pos_x">{{ __('admin.stores.pos_x') }}</label>
|
||
<input id="pos_x" type="number" name="pos_x" step="0.1" required
|
||
value="{{ old('pos_x', $store->pos_x ?? 0) }}">
|
||
</div>
|
||
<div class="field" style="margin:0;">
|
||
<label for="pos_y">{{ __('admin.stores.pos_y') }}</label>
|
||
<input id="pos_y" type="number" name="pos_y" step="0.1" required
|
||
value="{{ old('pos_y', $store->pos_y ?? 0) }}">
|
||
</div>
|
||
<div class="field" style="margin:0;">
|
||
<label for="pos_z">{{ __('admin.stores.pos_z') }}</label>
|
||
<input id="pos_z" type="number" name="pos_z" step="0.1" required
|
||
value="{{ old('pos_z', $store->pos_z ?? 0) }}">
|
||
</div>
|
||
</div>
|
||
@error('pos_x')<div class="error">{{ $message }}</div>@enderror
|
||
@error('pos_y')<div class="error">{{ $message }}</div>@enderror
|
||
@error('pos_z')<div class="error">{{ $message }}</div>@enderror
|
||
|
||
<div style="display:grid; grid-template-columns:repeat(3, 1fr); gap:.75rem; margin-top:.75rem;">
|
||
<div class="field" style="margin:0;">
|
||
<label for="width">{{ __('admin.stores.width') }}</label>
|
||
<input id="width" type="number" name="width" step="0.1" min="0.1"
|
||
value="{{ old('width', $store->width ?? 8) }}">
|
||
</div>
|
||
<div class="field" style="margin:0;">
|
||
<label for="height">{{ __('admin.stores.height') }}</label>
|
||
<input id="height" type="number" name="height" step="0.1" min="0.1"
|
||
value="{{ old('height', $store->height ?? 6) }}">
|
||
</div>
|
||
<div class="field" style="margin:0;">
|
||
<label for="depth">{{ __('admin.stores.depth') }}</label>
|
||
<input id="depth" type="number" name="depth" step="0.1" min="0.1"
|
||
value="{{ old('depth', $store->depth ?? 8) }}">
|
||
</div>
|
||
</div>
|
||
@error('width')<div class="error">{{ $message }}</div>@enderror
|
||
@error('height')<div class="error">{{ $message }}</div>@enderror
|
||
@error('depth')<div class="error">{{ $message }}</div>@enderror
|
||
</fieldset>
|
||
|
||
<div style="display:flex; gap:.75rem; margin-top:1.25rem;">
|
||
<button type="submit" class="btn btn-primary" style="flex:1; padding:.8rem;">{{ __('Save') }}</button>
|
||
<a href="{{ route('admin.stores.index') }}" class="btn" style="padding:.8rem;">{{ __('Cancel') }}</a>
|
||
</div>
|
||
</div>
|
||
|
||
{{-- 3D position picker --}}
|
||
<div class="card" style="padding:1rem;">
|
||
<p style="margin:0 0 .6rem; color:var(--muted); font-size:.85rem;">{{ __('admin.stores.position_hint') }}</p>
|
||
|
||
<div id="picker"
|
||
data-pos-x="{{ old('pos_x', $store->pos_x ?? 0) }}"
|
||
data-pos-y="{{ old('pos_y', $store->pos_y ?? 0) }}"
|
||
data-pos-z="{{ old('pos_z', $store->pos_z ?? 0) }}"
|
||
data-width="{{ old('width', $store->width ?? 8) }}"
|
||
data-height="{{ old('height', $store->height ?? 6) }}"
|
||
data-depth="{{ old('depth', $store->depth ?? 8) }}"
|
||
style="position:relative; width:100%; height:440px; border-radius:.75rem; overflow:hidden; background:#0b1220; border:1px solid var(--border);">
|
||
</div>
|
||
|
||
<p id="picker-readout" style="margin:.6rem 0 0; font-family:ui-monospace, monospace; font-size:.85rem; color:#7dd3fc;"></p>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
@endsection
|
||
|
||
@push('scripts')
|
||
<script type="module">
|
||
import * as THREE from '/vendor/three/three.module.min.js';
|
||
|
||
const host = document.getElementById('picker');
|
||
const readout = document.getElementById('picker-readout');
|
||
const fX = document.getElementById('pos_x');
|
||
const fY = document.getElementById('pos_y');
|
||
const fZ = document.getElementById('pos_z');
|
||
const fW = document.getElementById('width');
|
||
const fH = document.getElementById('height');
|
||
const fD = document.getElementById('depth');
|
||
|
||
const scene = new THREE.Scene();
|
||
scene.background = new THREE.Color(0x0b1220);
|
||
scene.fog = new THREE.Fog(0x0b1220, 80, 260);
|
||
|
||
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 1000);
|
||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
host.appendChild(renderer.domElement);
|
||
|
||
// Lights
|
||
scene.add(new THREE.AmbientLight(0xffffff, 0.65));
|
||
const sun = new THREE.DirectionalLight(0xffffff, 0.9);
|
||
sun.position.set(30, 60, 20);
|
||
scene.add(sun);
|
||
|
||
// Ground plane (raycast target)
|
||
const groundMat = new THREE.MeshStandardMaterial({ color: 0x14532d, roughness: 1 });
|
||
const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), groundMat);
|
||
ground.rotation.x = -Math.PI / 2;
|
||
ground.name = 'ground';
|
||
scene.add(ground);
|
||
|
||
const grid = new THREE.GridHelper(400, 80, 0x334155, 0x1f2937);
|
||
grid.position.y = 0.01;
|
||
scene.add(grid);
|
||
|
||
// Preview box for the store being edited
|
||
const boxMat = new THREE.MeshStandardMaterial({ color: 0x0ea5e9, transparent: true, opacity: 0.85 });
|
||
const box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), boxMat);
|
||
scene.add(box);
|
||
|
||
// Outline wireframe for clarity
|
||
const wire = new THREE.LineSegments(
|
||
new THREE.EdgesGeometry(new THREE.BoxGeometry(1, 1, 1)),
|
||
new THREE.LineBasicMaterial({ color: 0xffffff })
|
||
);
|
||
scene.add(wire);
|
||
|
||
// Marker pin at origin of the box
|
||
const pin = new THREE.Mesh(
|
||
new THREE.SphereGeometry(0.5, 16, 16),
|
||
new THREE.MeshBasicMaterial({ color: 0xf59e0b })
|
||
);
|
||
scene.add(pin);
|
||
|
||
// Existing stores as translucent ghosts (fetched from world data)
|
||
async function loadExistingStores() {
|
||
try {
|
||
const res = await fetch('{{ route('city.data') }}', { headers: { 'Accept': 'application/json' } });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
const ghostMat = new THREE.MeshStandardMaterial({ color: 0x64748b, transparent: true, opacity: 0.4 });
|
||
(data.stores || []).forEach(s => {
|
||
const [sx, sy, sz] = s.position;
|
||
const [w, h, d] = s.size;
|
||
const m = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), ghostMat);
|
||
m.position.set(sx, sy + h / 2, sz);
|
||
scene.add(m);
|
||
});
|
||
} catch (e) { /* non-fatal */ }
|
||
}
|
||
loadExistingStores();
|
||
|
||
// Camera orbit state
|
||
const target = new THREE.Vector3(0, 0, 0);
|
||
let radius = 45, theta = Math.PI / 4, phi = Math.PI / 3.2;
|
||
|
||
function updateCamera() {
|
||
const sinPhi = Math.sin(phi);
|
||
camera.position.set(
|
||
target.x + radius * sinPhi * Math.sin(theta),
|
||
target.y + radius * Math.cos(phi),
|
||
target.z + radius * sinPhi * Math.cos(theta)
|
||
);
|
||
camera.lookAt(target);
|
||
}
|
||
|
||
function syncBox() {
|
||
const px = parseFloat(fX.value) || 0;
|
||
const py = parseFloat(fY.value) || 0;
|
||
const pz = parseFloat(fZ.value) || 0;
|
||
const w = Math.max(0.1, parseFloat(fW.value) || 1);
|
||
const h = Math.max(0.1, parseFloat(fH.value) || 1);
|
||
const d = Math.max(0.1, parseFloat(fD.value) || 1);
|
||
|
||
box.scale.set(w, h, d);
|
||
box.position.set(px, py + h / 2, pz);
|
||
wire.scale.set(w, h, d);
|
||
wire.position.copy(box.position);
|
||
pin.position.set(px, py + 0.5, pz);
|
||
readout.textContent = `X ${px.toFixed(2)} · Y ${py.toFixed(2)} · Z ${pz.toFixed(2)} | ${w.toFixed(1)}×${h.toFixed(1)}×${d.toFixed(1)}`;
|
||
updateCamera();
|
||
}
|
||
|
||
// Sync inputs -> box
|
||
[fX, fY, fZ, fW, fH, fD].forEach(el => el.addEventListener('input', syncBox));
|
||
|
||
// Raycast click on ground to set X/Z
|
||
const raycaster = new THREE.Raycaster();
|
||
const mouse = new THREE.Vector2();
|
||
|
||
function pointerToGround(ev) {
|
||
const rect = renderer.domElement.getBoundingClientRect();
|
||
mouse.x = ((ev.clientX - rect.left) / rect.width) * 2 - 1;
|
||
mouse.y = -((ev.clientY - rect.top) / rect.height) * 2 + 1;
|
||
raycaster.setFromCamera(mouse, camera);
|
||
const hit = raycaster.intersectObject(ground, false)[0];
|
||
if (hit) {
|
||
fX.value = hit.point.x.toFixed(2);
|
||
fZ.value = hit.point.z.toFixed(2);
|
||
syncBox();
|
||
}
|
||
}
|
||
|
||
// Orbit controls (drag rotate, wheel zoom, right-drag pan)
|
||
let dragging = false, panning = false, lastX = 0, lastY = 0, moved = false;
|
||
|
||
renderer.domElement.addEventListener('pointerdown', (e) => {
|
||
moved = false;
|
||
lastX = e.clientX; lastY = e.clientY;
|
||
if (e.button === 2 || e.shiftKey) { panning = true; }
|
||
else { dragging = true; }
|
||
renderer.domElement.setPointerCapture(e.pointerId);
|
||
});
|
||
|
||
renderer.domElement.addEventListener('pointermove', (e) => {
|
||
if (!dragging && !panning) return;
|
||
const dx = e.clientX - lastX, dy = e.clientY - lastY;
|
||
if (Math.abs(dx) > 2 || Math.abs(dy) > 2) moved = true;
|
||
lastX = e.clientX; lastY = e.clientY;
|
||
|
||
if (dragging) {
|
||
theta -= dx * 0.005;
|
||
phi = Math.min(Math.PI * 0.49, Math.max(0.12, phi - dy * 0.005));
|
||
updateCamera();
|
||
} else if (panning) {
|
||
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0);
|
||
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1);
|
||
target.addScaledVector(right, -dx * radius * 0.0016);
|
||
target.addScaledVector(up, dy * radius * 0.0016);
|
||
updateCamera();
|
||
}
|
||
});
|
||
|
||
renderer.domElement.addEventListener('pointerup', (e) => {
|
||
// Treat as click (place marker) only if it wasn't a drag/pan.
|
||
if (moved) {
|
||
dragging = panning = false;
|
||
return;
|
||
}
|
||
const wasPanning = panning;
|
||
dragging = panning = false;
|
||
if (!wasPanning && e.button === 0) pointerToGround(e);
|
||
});
|
||
|
||
renderer.domElement.addEventListener('wheel', (e) => {
|
||
e.preventDefault();
|
||
radius = Math.min(240, Math.max(8, radius + e.deltaY * 0.05));
|
||
updateCamera();
|
||
}, { passive: false });
|
||
|
||
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
|
||
|
||
// Basic touch support: 1 finger orbit, 2 finger pinch zoom
|
||
let touchDist = 0;
|
||
renderer.domElement.addEventListener('touchstart', (e) => {
|
||
if (e.touches.length === 2) {
|
||
touchDist = Math.hypot(
|
||
e.touches[0].clientX - e.touches[1].clientX,
|
||
e.touches[0].clientY - e.touches[1].clientY
|
||
);
|
||
}
|
||
}, { passive: true });
|
||
|
||
renderer.domElement.addEventListener('touchmove', (e) => {
|
||
if (e.touches.length === 2) {
|
||
const d = Math.hypot(
|
||
e.touches[0].clientX - e.touches[1].clientX,
|
||
e.touches[0].clientY - e.touches[1].clientY
|
||
);
|
||
radius = Math.min(240, Math.max(8, radius + (touchDist - d) * 0.15));
|
||
touchDist = d;
|
||
updateCamera();
|
||
e.preventDefault();
|
||
}
|
||
}, { passive: false });
|
||
|
||
// Resize
|
||
function resize() {
|
||
const w = host.clientWidth, h = host.clientHeight;
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setSize(w, h, false);
|
||
updateCamera();
|
||
}
|
||
new ResizeObserver(resize).observe(host);
|
||
resize();
|
||
|
||
syncBox();
|
||
|
||
(function animate() {
|
||
requestAnimationFrame(animate);
|
||
renderer.render(scene, camera);
|
||
})();
|
||
</script>
|
||
@endpush
|
||
|
||
@push('styles')
|
||
<style>
|
||
@media (max-width: 860px) {
|
||
form > div[style*="grid-template-columns"] { grid-template-columns: 1fr !important; }
|
||
#picker { height: 340px !important; }
|
||
}
|
||
</style>
|
||
@endpush |