This commit is contained in:
StrangeD0s
2026-02-07 20:11:26 +01:00
parent 817cdc7906
commit 81a53e441e
15 changed files with 1069 additions and 86 deletions
-20
View File
@@ -1,20 +0,0 @@
// app/Apitest.tsx
export default async function Apitest() {
// 1. Fetch the data directly in the component
const res = await fetch('https://swapi.py4e.com/api/people/1/');
const data = await res.json();
// 2. This console.log will appear in your TERMINAL, not the browser console
// because this is a Server Component!
console.log("Fetched Pilot:", data.name);
return (
<div className="p-4 border border-yellow-500 rounded-md bg-black/20">
<h2 className="text-xl font-bold text-yellow-400">Smuggler Intel</h2>
<p>Name: {data.name}</p>
<p>Height: {data.height}cm</p>
<p>Mass: {data.mass}kg</p>
</div>
);
}
+5
View File
@@ -5,6 +5,11 @@
--foreground: #171717;
}
@theme {
/* Hier registrierst du die Font für Tailwind v4 */
--font-starjedi: var(--font-starjedi);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
+10 -3
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import localFont from "next/font/local"; // Wichtig: localFont importieren
import "./globals.css";
const geistSans = Geist({
@@ -12,9 +13,15 @@ const geistMono = Geist_Mono({
subsets: ["latin"],
});
// Konfiguration der lokalen Star Jedi Font
const starJedi = localFont({
src: "../public/fonts/Starjedi.ttf",
variable: "--font-starjedi",
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Outer Rim Smuggler",
description: "A Star Wars inspired roguelike adventure",
};
export default function RootLayout({
@@ -23,7 +30,7 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="en" className={`${starJedi.variable}`}>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
+77 -63
View File
@@ -1,73 +1,87 @@
import Image from "next/image";
import Apitest from "./components/apitest";
"use client";
import { useGameEngine } from "@/hooks/useGameEngine";
import HUD from "../components/Hud";
import GalaxyMap from "../components/GalaxyMap";
import EventLog from "../components/EventLog";
import ActionPanel from "../components/ActionPanel";
import StartScreen from "@/components/StartScreen";
import GameOverScreen from "@/components/GameOverScreen";
import { t } from "@/content/locales"; // Import der Locales
export default function Home() {
const {
gameStarted,
startGame,
planets,
credits,
hp,
loading,
currentLocationId,
selectedPlanet,
setSelectedPlanet,
handleAttack,
executeJump,
isJumping,
activeEnemy,
executeEscape,
logs,
isGameOver,
restartGame,
} = useGameEngine();
// 1. Ladezustand
if (loading) {
return (
<div className="min-h-screen bg-black flex items-center justify-center font-mono text-yellow-500">
<div className="animate-pulse tracking-[0.3em]">{t.SYSTEM.LOADING}</div>
</div>
);
}
// Aktuellen Planeten finden für das HUD
const currentPlanet = planets.find((p) => p.id === currentLocationId);
// 2. Startbildschirm
if (!gameStarted) {
return <StartScreen onStart={startGame} />;
}
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="min-h-screen bg-[#050505] pt-24 px-4 flex flex-col items-center">
<HUD
credits={credits}
hp={hp}
maxHp={10} // Könnte später aus einem Ship-Objekt kommen
location={currentPlanet?.name || t.SYSTEM.DEEP_SPACE}
targetCredits={100000}
/>
{/* Add your component here */}
<Apitest />
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
<div className="w-full max-w-4xl flex flex-col items-center gap-6">
<GalaxyMap
planets={planets}
onSelectLocation={(p) => setSelectedPlanet(p)}
currentLocationId={currentLocationId}
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
{/* ActionPanel erscheint bei Zielwahl oder Feindkontakt */}
{(selectedPlanet || activeEnemy) && (
<ActionPanel
planet={selectedPlanet}
enemy={activeEnemy}
isLoading={isJumping}
onAction={activeEnemy ? handleAttack : executeJump}
onEscape={executeEscape}
/>
)}
</div>
<EventLog logs={logs} />
{/* Game Over Overlay */}
{isGameOver && (
<GameOverScreen credits={credits} onRestart={restartGame} />
)}
</main>
);
}
+230
View File
@@ -0,0 +1,230 @@
"use client";
import React from "react";
import { Planet, Starship } from "@/types/swapi";
import { t } from "@/content/locales";
interface ActionPanelProps {
planet?: Planet | null;
enemy?: Starship | null;
onAction: () => void;
onEscape?: () => void;
isLoading?: boolean;
}
const getPlanetTheme = (terrain: string = "", climate: string = "") => {
const terr = terrain.toLowerCase();
const clim = climate.toLowerCase();
if (terr.includes("ocean") || terr.includes("water"))
return {
color: "text-blue-400",
border: "border-blue-500",
glow: "shadow-[0_0_20px_#3b82f6]",
label: t.ACTION_PANEL.MODES.AQUATIC,
};
if (terr.includes("forest") || terr.includes("jungle"))
return {
color: "text-green-400",
border: "border-green-500",
glow: "shadow-[0_0_20px_#22c55e]",
label: t.ACTION_PANEL.MODES.VERDANT,
};
if (terr.includes("desert") || clim.includes("arid"))
return {
color: "text-orange-400",
border: "border-orange-500",
glow: "shadow-[0_0_20px_#f97316]",
label: t.ACTION_PANEL.MODES.ARID,
};
if (
terr.includes("ice") ||
terr.includes("glacier") ||
clim.includes("frozen")
)
return {
color: "text-cyan-200",
border: "border-cyan-300",
glow: "shadow-[0_0_20px_#a5f3fc]",
label: t.ACTION_PANEL.MODES.CRYO,
};
if (terr.includes("city") || terr.includes("urban"))
return {
color: "text-purple-400",
border: "border-purple-500",
glow: "shadow-[0_0_20px_#a855f7]",
label: t.ACTION_PANEL.MODES.ECUMENOPOLIS,
};
return {
color: "text-yellow-500",
border: "border-yellow-500",
glow: "shadow-[0_0_20px_#eab308]",
label: t.ACTION_PANEL.MODES.STANDARD,
};
};
export default function ActionPanel({
planet,
enemy,
onAction,
onEscape,
isLoading,
}: ActionPanelProps) {
const isCombat = !!enemy;
const theme =
!isCombat && planet
? getPlanetTheme(planet.terrain, planet.climate)
: {
color: "text-red-500",
border: "border-red-600",
glow: "shadow-[0_0_30px_#dc2626]",
label: t.ACTION_PANEL.MODES.HOSTILE,
};
const displayName = isCombat ? enemy.name : planet?.name;
const displaySubtitle = isCombat
? `${t.ACTION_PANEL.LABELS.CLASS}: ${enemy.starship_class} // ${t.ACTION_PANEL.LABELS.CREW}: ${enemy.crew}`
: `${t.ACTION_PANEL.LABELS.SECTOR}: ${planet?.climate} // ${planet?.terrain}`;
return (
<div
className={`w-full max-w-4xl mt-6 bg-black/90 backdrop-blur-md border-2 rounded-lg p-6 shadow-2xl animate-in fade-in slide-in-from-bottom-4 relative overflow-hidden transition-colors duration-500 ${theme.border}`}
>
<div
className={`absolute inset-0 bg-[linear-gradient(transparent_0%,${isCombat ? "rgba(220,38,38,0.1)" : "rgba(234,179,8,0.05)"}_50%,transparent_100%)] bg-[length:100%_4px] animate-pulse pointer-events-none`}
/>
<div className="flex flex-col md:flex-row gap-8 items-center relative z-10">
<div className="relative shrink-0">
<div
className={`w-32 h-32 rounded-full border-4 ${theme.border} ${theme.glow} flex items-center justify-center transition-all duration-700 bg-black overflow-hidden relative`}
>
{isCombat ? (
<div className="text-red-600 animate-pulse font-black text-5xl drop-shadow-[0_0_10px_rgba(220,38,38,0.8)]">
!
</div>
) : (
<>
<div
className="w-full h-full transition-colors duration-1000"
style={{
background: `radial-gradient(circle at 30% 30%, ${theme.color.replace("text-", "")}, #000)`,
backgroundColor: theme.color.includes("blue")
? "#60a5fa"
: theme.color.includes("green")
? "#4ade80"
: theme.color.includes("orange")
? "#fb923c"
: theme.color.includes("cyan")
? "#22d3ee"
: theme.color.includes("purple")
? "#c084fc"
: "#eab308",
}}
/>
<div className="absolute inset-0 bg-gradient-to-tr from-black/60 via-transparent to-white/20 pointer-events-none" />
<div className="absolute inset-0 opacity-20 bg-[url('https://www.transparenttextures.com/patterns/carbon-fibre.png')] animate-spin-slow mix-blend-overlay" />
</>
)}
</div>
<div
className={`absolute -top-2 -right-2 bg-black border px-2 py-0.5 text-[8px] uppercase tracking-widest ${isCombat ? "border-red-600 text-red-600" : "border-yellow-500/50 text-yellow-500"}`}
>
{theme.label}
</div>
</div>
<div className="flex-1 text-center md:text-left space-y-3">
<div>
<h3
className={`text-3xl font-black tracking-widest uppercase ${isCombat ? "text-red-600" : "text-white"}`}
>
{displayName}
</h3>
<p
className={`${theme.color} text-xs font-bold uppercase tracking-widest opacity-80`}
>
{displaySubtitle}
</p>
</div>
<div
className={`grid grid-cols-2 gap-4 border-t border-b py-3 ${isCombat ? "border-red-900/30" : "border-yellow-500/10"}`}
>
<div className="text-[10px] uppercase">
<span
className={isCombat ? "text-red-500/50" : "text-yellow-500/50"}
>
{isCombat
? t.ACTION_PANEL.LABELS.MANUFACTURER
: t.ACTION_PANEL.LABELS.POPULATION}
</span>
<p className="text-white font-mono truncate">
{isCombat
? enemy.manufacturer
: planet?.population.toLocaleString()}
</p>
</div>
<div className="text-[10px] uppercase">
<span
className={isCombat ? "text-red-500/50" : "text-yellow-500/50"}
>
{isCombat
? t.ACTION_PANEL.LABELS.COST
: t.ACTION_PANEL.LABELS.GRAVITY}
</span>
<p className="text-white font-mono">
{isCombat ? `${enemy.cost_in_credits} Cr` : planet?.gravity}
</p>
</div>
</div>
<p
className={`text-xs italic leading-relaxed ${isCombat ? "text-red-400/60" : "text-yellow-500/60"}`}
>
{isCombat
? t.ACTION_PANEL.STATUS.COMBAT_WARNING
: t.ACTION_PANEL.STATUS.PLANET_SCAN}
</p>
</div>
<div className="flex flex-col gap-3 shrink-0 w-full md:w-auto">
<button
onClick={onAction}
disabled={isLoading}
className={`w-full md:w-auto px-10 py-5 border-2 font-black uppercase tracking-[0.3em] transition-all relative group
${
isCombat
? "border-red-600 text-red-600 hover:bg-red-600 hover:text-white shadow-[0_0_15px_rgba(220,38,38,0.3)]"
: "border-yellow-500 text-yellow-500 hover:bg-yellow-500 hover:text-black hover:shadow-[0_0_30px_#eab308]"
} ${isLoading ? "opacity-50 cursor-not-allowed" : ""}`}
>
{isLoading ? (
<span className="flex items-center gap-2">
<span
className={`animate-ping inline-flex h-2 w-2 rounded-full ${isCombat ? "bg-red-600" : "bg-yellow-500"}`}
></span>
{t.ACTION_PANEL.STATUS.ENGAGING}
</span>
) : isCombat ? (
t.ACTION_PANEL.BUTTONS.FIRE
) : (
t.ACTION_PANEL.BUTTONS.JUMP
)}
</button>
{isCombat && (
<button
onClick={onEscape}
className="px-10 py-2 border border-white/20 text-white/40 text-[10px] uppercase tracking-widest hover:bg-white/5 hover:text-white transition-all"
>
{t.ACTION_PANEL.BUTTONS.ESCAPE}
</button>
)}
</div>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import React, { useEffect, useRef } from "react";
import { t } from "@/content/locales"; // Import der Locales
export interface LogEntry {
id: string;
message: string;
type: "info" | "success" | "danger" | "warning";
timestamp: string;
}
interface EventLogProps {
logs: LogEntry[];
}
export default function EventLog({ logs }: EventLogProps) {
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-Scroll nach unten bei neuen Einträgen
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const getTypeStyles = (type: LogEntry["type"]) => {
switch (type) {
case "success":
return "text-green-400";
case "danger":
return "text-red-500 font-bold animate-pulse";
case "warning":
return "text-orange-400";
default:
return "text-yellow-500/80";
}
};
return (
<div className="fixed bottom-6 right-6 w-full max-w-md h-48 bg-black/80 border border-yellow-500/30 rounded-lg shadow-2xl overflow-hidden flex flex-col backdrop-blur-md z-40">
{/* Header */}
<div className="bg-yellow-500/10 px-4 py-1 border-b border-yellow-500/20 flex justify-between items-center">
<span className="text-[10px] font-mono text-yellow-500 uppercase tracking-widest">
{t.EVENT_LOG.TITLE}
</span>
</div>
{/* Scrollable Content */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 font-mono text-sm scrollbar-thin scrollbar-thumb-yellow-500/20"
>
{logs.length === 0 && (
<p className="text-yellow-500/20 italic text-xs">
{t.EVENT_LOG.EMPTY_STATE}
</p>
)}
{logs.map((log) => (
<div
key={log.id}
className="mb-2 flex gap-3 border-l border-yellow-500/10 pl-2"
>
<span className="text-[10px] opacity-80 mt-1 shrink-0 text-white">
{log.timestamp}
</span>
<span className={`${getTypeStyles(log.type)} leading-tight`}>
{log.type === "danger" && "⚠ "}
{log.message}
</span>
</div>
))}
</div>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import React from "react";
import { Planet } from "@/types/swapi";
import { t } from "@/content/locales"; // Import der Locales
interface GalaxyMapProps {
planets: Planet[];
onSelectLocation: (planet: Planet) => void;
currentLocationId?: string;
}
export default function GalaxyMap({
planets,
onSelectLocation,
currentLocationId,
}: GalaxyMapProps) {
return (
<div className="relative w-full max-w-4xl h-[500px] bg-[#080808] border-2 border-yellow-500/20 rounded-xl overflow-hidden shadow-[inset_0_0_50px_rgba(0,0,0,1)] z-10">
{/* Hintergrund-Raster (Grid) */}
<div
className="absolute inset-0 opacity-10 pointer-events-none"
style={{
backgroundImage:
"linear-gradient(#eab308 1px, transparent 1px), linear-gradient(90deg, #eab308 1px, transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
{/* Die Planeten (Nodes) aus der API */}
{planets.map((planet) => {
const isCurrent = planet.id === currentLocationId;
return (
<button
key={planet.id}
onClick={() => !isCurrent && onSelectLocation(planet)}
disabled={isCurrent}
className={`absolute group transform -translate-x-1/2 -translate-y-1/2 transition-all
${isCurrent ? "cursor-default" : "cursor-pointer hover:scale-110"}`}
style={{ left: `${planet.x}%`, top: `${planet.y}%` }}
>
{/* Planet Visuell */}
<div
className={`w-4 h-4 rounded-full border-2 transition-all duration-300
${
isCurrent
? "bg-blue-500 border-white shadow-[0_0_15px_#3b82f6] scale-125"
: "bg-yellow-500/20 border-yellow-500 group-hover:bg-yellow-500 group-hover:shadow-[0_0_10px_#eab308]"
}`}
/>
{/* Label */}
<div className="absolute top-6 left-1/2 -translate-x-1/2 whitespace-nowrap">
<span
className={`text-[10px] font-mono uppercase tracking-tighter px-1 rounded bg-black/50
${isCurrent ? "text-blue-400 font-bold" : "text-yellow-500/60 group-hover:text-yellow-500"}`}
>
{planet.name}
</span>
</div>
{/* Pulsierender Ring für aktuellen Standort */}
{isCurrent && (
<div className="absolute -inset-2 border border-blue-500/50 rounded-full animate-ping pointer-events-none" />
)}
</button>
);
})}
{/* Scan-Linie Effekt */}
<div className="absolute top-0 left-0 w-full h-1 bg-yellow-500/5 opacity-20 animate-scan pointer-events-none" />
{/* Info Overlay */}
<div className="absolute bottom-4 left-4 text-[10px] text-yellow-500/40 font-mono uppercase pointer-events-none bg-black/40 p-2 rounded">
{t.GALAXY_MAP.SCAN_STATUS} <br />
{t.GALAXY_MAP.OBJECTS_DETECTED(planets.length)} <br />
{t.GALAXY_MAP.HOLONET_STATUS}
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
"use client";
import React from "react";
import { t } from "@/content/locales";
interface GameOverScreenProps {
credits: number;
onRestart: () => void;
}
export default function GameOverScreen({
credits,
onRestart,
}: GameOverScreenProps) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90 backdrop-blur-xl animate-in fade-in duration-1000">
<div className="text-center space-y-6 p-12 border-2 border-red-600 bg-red-950/20 rounded-lg shadow-[0_0_50px_rgba(220,38,38,0.5)]">
<h1 className="text-6xl font-black text-red-600 tracking-tighter uppercase italic">
{t.GAME_OVER.TITLE}
</h1>
<p className="text-gray-400 font-mono">{t.GAME_OVER.SUBTITLE}</p>
<div className="py-4">
<p className="text-sm text-gray-500 uppercase tracking-widest">
{t.GAME_OVER.SCORE_LABEL}
</p>
<p className="text-3xl font-bold text-yellow-500">
{credits.toLocaleString()} Credits
</p>
</div>
<button
onClick={onRestart}
className="px-8 py-4 bg-red-600 hover:bg-red-700 text-white font-bold uppercase tracking-widest transition-all transform hover:scale-105 active:scale-95 shadow-[0_0_20px_rgba(220,38,38,0.4)]"
>
{t.GAME_OVER.RESTART_BUTTON}
</button>
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
"use client";
import React from "react";
import { t } from "@/content/locales";
interface HUDProps {
credits: number;
hp: number;
maxHp: number;
location: string;
targetCredits: number;
}
export default function HUD({
credits,
hp,
maxHp,
location,
targetCredits,
}: HUDProps) {
const progress = Math.min((credits / targetCredits) * 100, 100);
return (
<header className="fixed top-0 left-0 w-full bg-black border-b-2 border-yellow-500/50 p-4 font-mono text-yellow-500 z-50 shadow-[0_0_15px_rgba(234,179,8,0.2)]">
<div className="max-w-7xl mx-auto flex flex-wrap justify-between items-center gap-4">
{/* LORE & LOCATION */}
<div className="flex flex-col">
<span className="text-xs uppercase opacity-70">
{t.HUD.SECTOR_LABEL}
</span>
<span className="text-xl font-bold tracking-widest">
{location.toUpperCase()}
</span>
</div>
{/* SHIP STATUS (HEALTH) */}
<div className="flex flex-col min-w-[200px]">
<div className="flex justify-between text-xs uppercase mb-1">
<span>{t.HUD.HULL_LABEL}</span>
<span className={hp <= 3 ? "text-red-500 animate-pulse" : ""}>
{hp} / {maxHp}
</span>
</div>
<div className="w-full h-3 bg-gray-900 border border-yellow-500/30">
<div
className={`h-full transition-all duration-500 ${hp <= 3 ? "bg-red-600" : "bg-yellow-500"}`}
style={{ width: `${(hp / maxHp) * 100}%` }}
/>
</div>
</div>
{/* ECONOMY (CREDITS) */}
<div className="flex flex-col items-end">
<span className="text-xs uppercase opacity-70">
{t.HUD.GOAL_LABEL}
</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-green-400">
{credits.toLocaleString()}
</span>
<span className="text-xs opacity-50">
/ {targetCredits.toLocaleString()}
</span>
</div>
{/* Progress Bar for the Goal */}
<div className="w-32 h-1 bg-gray-800 mt-1">
<div
className="h-full bg-green-500 shadow-[0_0_5px_#22c55e]"
style={{ width: `${progress}%` }}
/>
</div>
</div>
</div>
</header>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import React from "react";
import { t } from "@/content/locales";
interface StartScreenProps {
onStart: () => void;
}
export default function StartScreen({ onStart }: StartScreenProps) {
return (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-[#050505] overflow-hidden">
{/* Hintergrund-Deko */}
<div className="absolute inset-0 opacity-20 bg-[radial-gradient(circle_at_center,_#eab308_0%,_transparent_70%)]" />
<div className="max-w-2xl w-full mx-4 relative z-10 text-center space-y-8 p-10 border border-yellow-500/20 bg-black/60 backdrop-blur-md rounded-2xl shadow-[0_0_100px_rgba(234,179,8,0.1)]">
<div className="space-y-2">
<h1 className="font-starjedi text-6xl text-white">
{t.UI.GAME_TITLE}
</h1>
<p className="text-yellow-500/60 font-mono tracking-[0.3em] uppercase text-xs">
{t.START_SCREEN.SUBTITLE}
</p>
</div>
<div className="space-y-4 text-gray-400 font-light leading-relaxed">
<p>
{t.START_SCREEN.INTRO_TEXT}
<span className="text-white font-medium">
{" "}
{t.START_SCREEN.MISSION_HIGHLIGHT}
</span>
</p>
<ul className="text-sm space-y-2 inline-block text-left border-l-2 border-yellow-500/30 pl-6">
<li>
{" "}
<strong className="text-yellow-500/80">
{t.START_SCREEN.FEATURE_NAV_TITLE}
</strong>{" "}
{t.START_SCREEN.FEATURE_NAV_DESC}
</li>
<li>
{" "}
<strong className="text-yellow-500/80">
{t.START_SCREEN.FEATURE_RISK_TITLE}
</strong>{" "}
{t.START_SCREEN.FEATURE_RISK_DESC}
</li>
<li>
{" "}
<strong className="text-yellow-500/80">
{t.START_SCREEN.FEATURE_COMBAT_TITLE}
</strong>{" "}
{t.START_SCREEN.FEATURE_COMBAT_DESC}
</li>
</ul>
</div>
<div className="pt-6">
<button
onClick={onStart}
className="group relative px-12 py-5 bg-yellow-500 text-black font-black uppercase tracking-[0.2em] transition-all hover:scale-105 hover:shadow-[0_0_40px_rgba(234,179,8,0.4)] overflow-hidden"
>
<span className="relative z-10">{t.UI.START_BUTTON}</span>
<div className="absolute inset-0 bg-white opacity-0 group-hover:opacity-20 transition-opacity" />
</button>
<p className="mt-4 text-[10px] text-gray-600 uppercase tracking-widest">
{t.START_SCREEN.FOOTER_ENGINE}
</p>
</div>
</div>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
// constants/locales.ts
export const locales = {
de: {
UI: {
GAME_TITLE: "Smuggler's Run",
SUBTITLE: "Fast Ships, faster credits!",
START_BUTTON: "Initialize Systems",
RESTART_BUTTON: "Klon-Zylinder aktivieren",
JUMP_BUTTON: "Initiate Jump",
ATTACK_BUTTON: "Feuer frei",
ESCAPE_BUTTON: "Fluchtversuch",
},
SYSTEM: {
LOADING: "INITIALIZING HOLONET LINK...",
DEEP_SPACE: "Deep Space",
},
START_SCREEN: {
SUBTITLE: "Fast Ships, faster credits!",
INTRO_TEXT:
"Smuggler's Run is a minimalist space adventure game, where you, the player, are an unlicensed space trader, flying cargo from planet to planet while trying not to be blown to pieces by bounty hunters, imperial forces or rebel do-gooders. Your mission is simple:",
MISSION_HIGHLIGHT: "Survive and earn credits",
FEATURE_NAV_TITLE: "Navigation:",
FEATURE_NAV_DESC: "Jump between sectors to deliver cargo.",
FEATURE_RISK_TITLE: "Risk:",
FEATURE_RISK_DESC:
"Hyperspace is treacherous. Imperial patrols are everywhere.",
FEATURE_COMBAT_TITLE: "Combat:",
FEATURE_COMBAT_DESC: "Use your 2D6 cannons to survive interceptions.",
FOOTER_ENGINE: "Star Wars API v1.0 // Engine: Tunnel Goons",
},
ACTION_PANEL: {
MODES: {
HOSTILE: "Hostile",
AQUATIC: "Aquatic",
VERDANT: "Verdant",
ARID: "Arid",
CRYO: "Cryo",
ECUMENOPOLIS: "Ecumenopolis",
STANDARD: "Standard",
},
LABELS: {
CLASS: "Class",
CREW: "Crew",
SECTOR: "Sector",
MANUFACTURER: "Manufacturer",
POPULATION: "Population",
COST: "Cost",
GRAVITY: "Gravity",
},
STATUS: {
COMBAT_WARNING:
"WARNING: Hostile contact confirmed. Weapons emitters charging.",
PLANET_SCAN:
"Receiving encrypted signals from sector. Landing clearance pending...",
ENGAGING: "Engaging...",
},
BUTTONS: {
FIRE: "Open Fire",
JUMP: "Initiate Jump",
ESCAPE: "Evasive Maneuvers",
},
},
GAME_OVER: {
TITLE: "Wasted in Space",
SUBTITLE: "Your ship has been reduced to stardust.",
SCORE_LABEL: "Final Profit",
RESTART_BUTTON: "Activate Clone Cylinder",
},
EVENT_LOG: {
TITLE: "Event Log",
EMPTY_STATE: "Waiting for signals...",
},
HUD: {
SECTOR_LABEL: "Current Sector",
HULL_LABEL: "Hull Integrity",
GOAL_LABEL: "Credits to Freedom", // Oder "Credits to Falcon"
},
GALAXY_MAP: {
SCAN_STATUS: "Sector Scan: Active",
OBJECTS_DETECTED: (count: number) => `Objects Detected: ${count}`,
HOLONET_STATUS: "HoloNet Status: Connected",
},
ENGINE: {
BOOT: {
SUCCESS: "HoloNet connection stable. Nav-computer ready.",
ERROR: "Critical error loading galaxy data!",
WELCOME: "Engines on standby. Nav-computer online. Welcome, Commander.",
RESTART: "Systems rebooting... A new journey begins.",
},
JUMP: {
INIT: (target: string) => `Hyperspace jump to ${target} initiated...`,
INTERCEPTION: (ship: string) =>
`INTERCEPTION! A ${ship} has pulled us out of hyperspace!`,
SUCCESS: (target: string, reward: number) =>
`Safe exit in the ${target} system. +${reward} Credits earned.`,
},
ESCAPE: {
INIT: "Emergency maneuvers initiated!",
FAIL: (ship: string, dmg: number) =>
`Escape failed! The ${ship} hits us hard. -${dmg} HP`,
SUCCESS: "Jump successful! We've shaken off the pursuer.",
},
COMBAT: {
INIT: (ship: string) =>
`Weapon systems synchronized. Opening fire on ${ship}!`,
VICTORY: (ship: string, loot: number) =>
`Direct hit! The ${ship} has been obliterated. +${loot} Credits salvaged.`,
COUNTER: (ship: string, dmg: number) =>
`Shields holding! The ${ship} counters. -${dmg} HP`,
},
SYSTEMS: {
CRITICAL_FAILURE:
"CRITICAL STRUCTURAL DAMAGE! The ship is breaking apart...",
},
DICE: {
ROLLING_1W6: "[Rolling 1D6...]",
ROLLING_2W6: "[Rolling 2D6...]",
RESULT_1W6: (res: number) => `[Roll: ${res}]`,
RESULT_2W6: (d1: number, d2: number, total: number) =>
`[Roll: ${d1} + ${d2} = ${total}]`,
},
},
},
// Später einfach erweiterbar:
// en: { ... }
};
// Aktuelle Sprache festlegen (könnte später aus einem State kommen)
export const t = locales.de;
+191
View File
@@ -0,0 +1,191 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { Planet, Starship } from "@/types/swapi";
import { fetchPlanets, fetchStarships } from "@/services/swapi";
import { LogEntry } from "@/components/EventLog";
import { t } from "@/content/locales";
export function useGameEngine() {
const [planets, setPlanets] = useState<Planet[]>([]);
const [starships, setStarships] = useState<Starship[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [gameStarted, setGameStarted] = useState(false);
const [isGameOver, setIsGameOver] = useState(false);
const [credits, setCredits] = useState<number>(100);
const [hp, setHp] = useState<number>(10);
const [currentLocationId, setCurrentLocationId] = useState<string>("");
const [selectedPlanet, setSelectedPlanet] = useState<Planet | null>(null);
const [isJumping, setIsJumping] = useState<boolean>(false);
const [activeEnemy, setActiveEnemy] = useState<Starship | null>(null);
const [logs, setLogs] = useState<LogEntry[]>([]);
const addLog = useCallback(
(message: string, type: LogEntry["type"] = "info") => {
const newLog: LogEntry = {
id: Math.random().toString(36).substring(2, 9),
message,
type,
timestamp: new Date().toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
}),
};
setLogs((prev) => [...prev, newLog]);
},
[],
);
useEffect(() => {
async function initGame() {
try {
const [planets, ships] = await Promise.all([
fetchPlanets(),
fetchStarships(),
]);
setPlanets(planets);
setStarships(ships);
if (planets.length > 0) setCurrentLocationId(planets[0].id);
addLog(t.ENGINE.BOOT.SUCCESS, "success");
} catch (error) {
addLog(t.ENGINE.BOOT.ERROR, "danger");
} finally {
setLoading(false);
}
}
initGame();
}, [addLog]);
const startGame = () => {
setGameStarted(true);
addLog(t.ENGINE.BOOT.WELCOME, "info");
};
const executeJump = async () => {
if (!selectedPlanet || selectedPlanet.id === currentLocationId) return;
setIsJumping(true);
addLog(
`${t.ENGINE.JUMP.INIT(selectedPlanet.name)} ${t.ENGINE.DICE.ROLLING_1W6}`,
"info",
);
setTimeout(() => {
const roll = Math.floor(Math.random() * 6) + 1;
const rollInfo = t.ENGINE.DICE.RESULT_1W6(roll);
if (roll >= 5) {
const randomShip =
starships[Math.floor(Math.random() * starships.length)];
setActiveEnemy(randomShip);
addLog(
`${rollInfo} ${t.ENGINE.JUMP.INTERCEPTION(randomShip.name)}`,
"danger",
);
} else {
const reward = 150;
setCredits((prev) => prev + reward);
setCurrentLocationId(selectedPlanet.id);
addLog(
`${rollInfo} ${t.ENGINE.JUMP.SUCCESS(selectedPlanet.name, reward)}`,
"success",
);
}
setIsJumping(false);
setSelectedPlanet(null);
}, 1200);
};
const executeEscape = () => {
if (!activeEnemy) return;
addLog(`${t.ENGINE.ESCAPE.INIT} ${t.ENGINE.DICE.ROLLING_1W6}`, "info");
setTimeout(() => {
const roll = Math.floor(Math.random() * 6) + 1;
const rollInfo = t.ENGINE.DICE.RESULT_1W6(roll);
if (roll <= 2) {
const damage = 1;
setHp((prev) => Math.max(0, prev - damage));
addLog(
`${rollInfo} ${t.ENGINE.ESCAPE.FAIL(activeEnemy.name, damage)}`,
"danger",
);
} else {
setActiveEnemy(null);
addLog(`${rollInfo} ${t.ENGINE.ESCAPE.SUCCESS}`, "success");
}
}, 800);
};
const handleAttack = () => {
if (!activeEnemy) return;
addLog(
`${t.ENGINE.COMBAT.INIT(activeEnemy.name)} ${t.ENGINE.DICE.ROLLING_2W6}`,
"info",
);
setTimeout(() => {
const d1 = Math.floor(Math.random() * 6) + 1;
const d2 = Math.floor(Math.random() * 6) + 1;
const total = d1 + d2;
const rollInfo = t.ENGINE.DICE.RESULT_2W6(d1, d2, total);
if (total >= activeEnemy.ds) {
const loot = 300;
setCredits((prev) => prev + loot);
setActiveEnemy(null);
addLog(
`${rollInfo} ${t.ENGINE.COMBAT.VICTORY(activeEnemy.name, loot)}`,
"success",
);
} else {
const diff = activeEnemy.ds - total;
setHp((prev) => Math.max(0, prev - diff));
addLog(
`${rollInfo} ${t.ENGINE.COMBAT.COUNTER(activeEnemy.name, diff)}`,
"danger",
);
}
}, 1000);
};
useEffect(() => {
if (hp <= 0 && !isGameOver) {
setIsGameOver(true);
addLog(t.ENGINE.SYSTEMS.CRITICAL_FAILURE, "danger");
}
}, [hp, isGameOver, addLog]);
const restartGame = () => {
setHp(10);
setCredits(100);
setIsGameOver(false);
setActiveEnemy(null);
setSelectedPlanet(null);
setLogs([]);
addLog(t.ENGINE.BOOT.RESTART, "success");
};
return {
planets,
starships,
loading,
gameStarted,
startGame,
credits,
hp,
currentLocationId,
selectedPlanet,
setSelectedPlanet,
executeEscape,
executeJump,
handleAttack,
isJumping,
activeEnemy,
logs,
isGameOver,
restartGame,
};
}
Binary file not shown.
+35
View File
@@ -0,0 +1,35 @@
import { SWAPIPlanet, SWAPIStarship, Planet, Starship } from "@/types/swapi";
const BASE_URL = "https://swapi.dev/api";
export async function fetchPlanets(): Promise<Planet[]> {
const res = await fetch(`${BASE_URL}/planets/`);
if (!res.ok) throw new Error("Galaxy data unavailable");
const data = await res.json();
return data.results.map(
(p: SWAPIPlanet): Planet => ({
...p,
id: p.url,
// Add random x/y coordinates for galaxy map
x: Math.floor(Math.random() * 80) + 10,
y: Math.floor(Math.random() * 70) + 15,
}),
);
}
export async function fetchStarships(): Promise<Starship[]> {
const res = await fetch(`${BASE_URL}/starships/`);
if (!res.ok) throw new Error("Imperial records unavailable");
const data = await res.json();
return data.results.map((s: SWAPIStarship): Starship => {
// Add difficulty score based on ship cost
const cost = parseInt(s.cost_in_credits);
let ds = 8;
if (isNaN(cost) || cost > 1000000) ds = 12;
else if (cost > 100000) ds = 10;
return { ...s, id: s.url, ds };
});
}
+41
View File
@@ -0,0 +1,41 @@
// types/swapi.ts
// Die korrigierten Rohdaten von der SWAPI
export interface SWAPIPlanet { // <-- Jetzt mit korrektem 'n'
name: string;
rotation_period: string;
orbital_period: string;
diameter: string;
climate: string;
gravity: string;
terrain: string;
surface_water: string;
population: string;
url: string;
}
export interface SWAPIStarship {
name: string;
model: string;
manufacturer: string;
cost_in_credits: string;
length: string;
max_atmosphering_speed: string;
crew: string;
passengers: string;
cargo_capacity: string;
starship_class: string;
url: string;
}
// Unsere erweiterten Typen für das Spiel
export interface Planet extends SWAPIPlanet {
id: string;
x: number;
y: number;
}
export interface Starship extends SWAPIStarship {
id: string;
ds: number; // Difficulty Score für Tunnel Goons
}