Compare commits
65 Commits
46494c29d9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 87dd8e1190 | |||
| 3bffb82560 | |||
| f181bf4de9 | |||
| 7dd6bfc40c | |||
| 0d080a8d94 | |||
| 613af7d7ea | |||
| b10641ee3c | |||
| 5b10d63970 | |||
| fcdd8306d2 | |||
| 01fb83d05f | |||
| 34024bab96 | |||
| 40df66e7cc | |||
| 61c628876f | |||
| 9aebddde86 | |||
| 8ae566d627 | |||
| 6c4f2e640a | |||
| 949a076ec7 | |||
| 14961d903b | |||
| ccac320e0e | |||
| ff3e66ae3e | |||
| 7c92f046a1 | |||
| 9de6a0b5a9 | |||
| 4603a64e10 | |||
| 36a172d07f | |||
| cb85c94f56 | |||
| 3ba7ef92ad | |||
| c326c363a0 | |||
| 7543fe3cb7 | |||
| 93480150e5 | |||
| 201d3be322 | |||
| b6aca99d0c | |||
| 44f58bc9c2 | |||
| 03c97ae3f2 | |||
| 836132b6ef | |||
| 3a6ca792aa | |||
| a8bfaaee36 | |||
| 902da64588 | |||
| e27eedb654 | |||
| a07e2e567f | |||
| 9cf23f8ad6 | |||
| 04623ea226 | |||
| 983104a173 | |||
| 4f9a792dea | |||
| a585dff09d | |||
| d584e418f5 | |||
| a41d39cc78 | |||
| 3ac613cb58 | |||
| ecccfddf7d | |||
| 361fb72b27 | |||
| bebdcf6049 | |||
| 07772f2093 | |||
| 13c1683d5e | |||
| e20ca4f016 | |||
| 6bb9b009c4 | |||
| 48c499e356 | |||
| 0a8861a5b1 | |||
| 2dd338c890 | |||
| 802f2bd34b | |||
| d30ab97f13 | |||
| 7929e7349a | |||
| cdc27eccc3 | |||
| de853c22bb | |||
| 1af93c897c | |||
| 97eff4f9eb | |||
| ff80b3cc0f |
@@ -10,13 +10,7 @@ target/
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
# RustRover
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
/assets/maps/autosave
|
||||
|
||||
# Added by cargo
|
||||
|
||||
|
||||
@@ -4,3 +4,8 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bladeink = "1.2.5"
|
||||
bytemuck = { version = "1.25.0", features = ["derive"] }
|
||||
pollster = "0.4.0"
|
||||
wgpu = "29.0.3"
|
||||
winit = "0.30.13"
|
||||
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
@@ -0,0 +1 @@
|
||||
asdsadas
|
||||
@@ -0,0 +1,279 @@
|
||||
// Cinema 24/7 -- Navigation
|
||||
// Lobby -> Foyer -> Labyrinth-Etagen -> Eingang Theater 4
|
||||
//
|
||||
// Variablen:
|
||||
// past_it -- Spieler ist an Theater 4 vorbeigegangen (bool)
|
||||
// snaks -- Spieler hat Snacks mitgenommen (0/1)
|
||||
// had_sweets -- Spieler hat süße Snacks gewählt (0/1)
|
||||
//
|
||||
// Übergang zu cinema_f4r4.ink via # start_ink Tag bei Eintreten in Theater 4.
|
||||
// KV-Store überträgt snaks/had_sweets/past_it automatisch ans nächste Script.
|
||||
|
||||
VAR past_it = false
|
||||
VAR snaks = 0
|
||||
VAR had_sweets = 0
|
||||
|
||||
-> the_cinema
|
||||
|
||||
|
||||
== the_cinema ==
|
||||
|
||||
The lobby has a very retro, synth-wave, neon, 70s look to it. The carpet alone makes your eyes hurt. This is not a cheeky metaphor for its ugliness. Your eyes really hurt when you stare at it for any length of time because the shades of blue and red, mixed together on it in geometric patterns, are so bright they make your vision dance in the low light. At a glance, something in the part of your brain that is responsible for processing visual data seems to short-circuit and you see the patterns move against each other, impossibly on the static floor.
|
||||
|
||||
* [>>>]
|
||||
-
|
||||
|
||||
Better look to the ceiling with its wavy cutouts backlit by colorful LED.
|
||||
|
||||
The ticket booth is unmanned of course, as is the snack dispensary. Which seems well stocked at least. The popcorn even looks fresh. Well, it smells fresh at least. The scent of pleasantly burned sugar is heavy in the air.
|
||||
|
||||
The ribbon barrier is closed but easy enough to bypass. Is it closed? It looks open. Though abandoned. But not for long. Not the abandonment of disrepair, only the momentary abandonment place like this experience between beginning and end of the shows. When all the visitors sit in their seats and the staff takes this moment to slip away for a quick smoke break. Something like that. Full behind doors but empty in the open.
|
||||
|
||||
* [>>>]
|
||||
-
|
||||
|
||||
You can even hear sounds that must be a film playing. Screams and gunfire. You hope someone left the door to that particular theatre open and that is why you can hear it so clearly. Because if the sound insulation is that shit, the movie going experience can't be very good here.
|
||||
|
||||
* [Slip under the ticket barrier]
|
||||
You raise the ribbon of the barrier and duck under it. Almost without a thought.
|
||||
-> the_foliar
|
||||
* [Go to the ticket booth]
|
||||
The graspable of the ticket booth is plastered with advertisements, movie posters and prohibition signs informing visitors of behaviours and attire within the cinema. The slip you took for a "closed" sign that hangs in the middle of the glass says the following.
|
||||
|
||||
"No tickets required. Proceed with caution."
|
||||
|
||||
On the little table where someone would place their tickets, there is another slip reading:
|
||||
|
||||
"Warning:
|
||||
|
||||
Everyone is free to enter.
|
||||
|
||||
But it is advised only to visit the auditoriums where no film is currently being played.
|
||||
|
||||
As the films are a bit extreme for most audiences.
|
||||
|
||||
Thank you for your visit!"
|
||||
** [Go into the cinema]
|
||||
You can open the flimsy barrier from here. It is secured right next to where you stand. So you enter the foliar.
|
||||
-> the_foliar
|
||||
** [Leave the cinema] You leave.
|
||||
-> END
|
||||
|
||||
-> the_foliar
|
||||
|
||||
|
||||
== the_foliar ==
|
||||
|
||||
The foliar is big. With couches to wait and meet and lounge on. The room is only broken up by advertising columns. Some of them display movie posters, though most are empty.
|
||||
|
||||
A long bar stretches the length of the back wall. Behind it chips and candy are stacked on shelves like those of an actual bar. It even has a mirrored backwall, which visually multiplies the colourful packaging. This pattern is only broken by enormous popcorn machines, which are full to the brim.
|
||||
|
||||
Do you want to grab a snack?
|
||||
* [Of course]
|
||||
~ snaks = 1
|
||||
** [Sweet] You grab a sweet snack. You are lucky. They have your favourite.
|
||||
~ had_sweets = 1
|
||||
** [Salty] You grab a salty snack. You are lucky. They have your favourite.
|
||||
* [I don't think so] You better not risk it.
|
||||
-
|
||||
To both sides of the room are stairs. This is a multi-floor cinema.
|
||||
-> groundfloor
|
||||
|
||||
|
||||
== groundfloor ==
|
||||
|
||||
+ [Go to the right staircase]
|
||||
{not (f1 > 0 or f2 > 0):
|
||||
You choose the right staircase to ascend to the first floor.
|
||||
|
||||
Pictures of actors in film scenes decorate the walls. They are probably supposed to be famous. Iconically recognisable. You know none of them. However, some catch your eye as familiar until you take a second look. They are certainly not cheap generic-property knockoffs to avoid copyright infringement. It's just a coincidence.
|
||||
|
||||
A big number: 1 marks the landing to the next floor.
|
||||
}
|
||||
{f1 > 0 or f2 > 0:
|
||||
You enter the first floor.
|
||||
}
|
||||
-> f1.r
|
||||
+ [Go to the left staircase]
|
||||
{not (f1 > 0 or f2 > 0):
|
||||
You choose the left staircase to ascend.
|
||||
|
||||
Pictures of actors in film scenes decorate the walls. They are probably supposed to be famous. Iconically recognisable. You know none of them. However, some catch your eye as familiar until you take a second look. They are certainly not cheap generic-property knockoffs to avoid copyright infringement. It's just a coincidence.
|
||||
|
||||
A big number: 2 marks the landing to the next floor.
|
||||
}
|
||||
{f1 > 0 or f2 > 0:
|
||||
You enter the second floor.
|
||||
}
|
||||
-> f2.l
|
||||
|
||||
|
||||
// ── Etagen-Labyrinth ────────────────────────────────────────────────────────
|
||||
// Die Stockwerk-Verbindungen sind absichtlich nicht-euklidisch.
|
||||
// Treppenauf/-ab führen nicht zu linear benachbarten Etagen.
|
||||
|
||||
== f1 ==
|
||||
= r
|
||||
+ [Go down the corridor.] -> f1.l
|
||||
+ [Take the stairs up.]
|
||||
You enter the fifth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f5.r
|
||||
+ [Take the stairs down.]
|
||||
You enter the sixth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f6.r
|
||||
|
||||
= l
|
||||
+ [Go down the corridor.] -> f1.r
|
||||
+ [Take the stairs up.]
|
||||
You enter the third floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f3.l
|
||||
+ [Take the stairs down.]
|
||||
You enter the seventh floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f7.l
|
||||
|
||||
|
||||
== f2 ==
|
||||
= r
|
||||
+ [Go down the corridor.] -> f2.l
|
||||
+ [Take the stairs up.]
|
||||
You enter the sixth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f6.r
|
||||
+ [Take the stairs down.]
|
||||
You enter the seventh floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f7.r
|
||||
|
||||
= l
|
||||
+ [Go down the corridor.] -> f2.r
|
||||
+ [Take the stairs up.]
|
||||
You enter the fifth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f5.l
|
||||
+ [Take the stairs down.]
|
||||
You enter the first floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f1.l
|
||||
|
||||
|
||||
== f3 ==
|
||||
= r
|
||||
+ [Go down the corridor.] -> f3.l
|
||||
+ [Take the stairs up.]
|
||||
You are back on the ground floor.
|
||||
-> groundfloor
|
||||
+ [Take the stairs down.]
|
||||
You enter the second floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f2.r
|
||||
|
||||
= l
|
||||
+ [Go down the corridor.] -> f3.r
|
||||
+ [Take the stairs up.]
|
||||
You enter the sixth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f6.l
|
||||
+ [Take the stairs down.]
|
||||
You enter the seventh floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f7.l
|
||||
|
||||
|
||||
== f4 ==
|
||||
= r
|
||||
+ [Go down the corridor.]
|
||||
{not past_it:
|
||||
You go down the corridor. It is empty. Some of the doors are closed. Some are slightly open. When you get close to the door of theatre 4 you can hear soft sounds. There is no film playing in the theatre according to the sign beside the door.
|
||||
- else:
|
||||
The corridor is still the same. And there are still the same faint noises coming out of theatre 4.
|
||||
}
|
||||
** [Enter]
|
||||
So you enter. # start_ink cinema_f4r4.ink.json
|
||||
-> END
|
||||
** [Go past it.]
|
||||
~ past_it = true
|
||||
You go past it.
|
||||
You walk closer to one wall. You want to run your fingers along it while you walk, but can't quite bring yourself to do so. To move the final millimetre closer to the wall. As if you are on tracks. Soon you are at the other side of the floor.
|
||||
-> f4.l
|
||||
+ [Take the stairs up.]
|
||||
You enter the fifth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f5.l
|
||||
+ [Take the stairs down.]
|
||||
You are back on the ground floor.
|
||||
-> groundfloor
|
||||
|
||||
= l
|
||||
+ [Go down the corridor.]
|
||||
{not past_it:
|
||||
You go down the corridor. It is empty. Some of the doors are closed. Some are slightly open. When you get close to the door of theatre 4 you can hear soft sounds. There is no film playing in the theatre according to the sign beside the door.
|
||||
- else:
|
||||
The corridor is still the same. And there are still the same faint noises coming out of theatre 4.
|
||||
}
|
||||
** [Enter]
|
||||
So you enter. # start_ink cinema_f4r4.ink.json
|
||||
-> END
|
||||
** [Go past it.]
|
||||
~ past_it = true
|
||||
You go past it.
|
||||
You walk closer to one wall. You want to run your fingers along it while you walk, but can't quite bring yourself to do so. To move the final millimetre closer to the wall. As if you are on tracks. Soon you are at the other side of the floor.
|
||||
-> f4.r
|
||||
+ [Take the stairs up.]
|
||||
You enter the seventh floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f7.l
|
||||
+ [Take the stairs down.]
|
||||
You are back on the ground floor.
|
||||
-> groundfloor
|
||||
|
||||
|
||||
== f5 ==
|
||||
= r
|
||||
+ [Go down the corridor.] -> f5.l
|
||||
+ [Take the stairs up.]
|
||||
You enter the second floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f2.r
|
||||
+ [Take the stairs down.]
|
||||
You enter the third floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f3.l
|
||||
|
||||
= l
|
||||
+ [Go down the corridor.] -> f5.r
|
||||
+ [Take the stairs up.]
|
||||
You enter the first floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f1.l
|
||||
+ [Take the stairs down.]
|
||||
You enter the sixth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f6.l
|
||||
|
||||
|
||||
== f6 ==
|
||||
= r
|
||||
+ [Go down the corridor.] -> f6.l
|
||||
+ [Take the stairs up.]
|
||||
You enter the first floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f1.r
|
||||
+ [Take the stairs down.]
|
||||
You enter the fourth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.}}
|
||||
-> f4.r
|
||||
|
||||
= l
|
||||
+ [Go down the corridor.] -> f6.r
|
||||
+ [Take the stairs up.]
|
||||
You are back on the ground floor.
|
||||
-> groundfloor
|
||||
+ [Take the stairs down.]
|
||||
You enter the third floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f3.l
|
||||
|
||||
|
||||
== f7 ==
|
||||
= r
|
||||
+ [Go down the corridor.] -> f7.l
|
||||
+ [Take the stairs up.]
|
||||
You enter the third floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f3.r
|
||||
+ [Take the stairs down.]
|
||||
You enter the fifth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f5.r
|
||||
|
||||
= l
|
||||
+ [Go down the corridor.] -> f7.r
|
||||
+ [Take the stairs up.]
|
||||
You enter the fourth floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.}}
|
||||
-> f4.l
|
||||
+ [Take the stairs down.]
|
||||
You enter the second floor. {~It looks like all the others.|||{~It looks the same as all the others.|More hallway. More carpet.|Just another floor}}
|
||||
-> f2.l
|
||||
|
||||
@@ -0,0 +1,856 @@
|
||||
// Cinema 24/7 -- Theater 4: Nails
|
||||
// Begegnung mit Nails, der Schattenkünstlerin.
|
||||
//
|
||||
// Variablen:
|
||||
// favor_nails -- Nails' Stimmung; > 0 = Sympathie, < 0 = Genervtheit
|
||||
// past_it -- aus cinema.ink via KV: Spieler ist an Theater 4 vorbeigegangen
|
||||
// snaks -- aus cinema.ink via KV: Spieler hat Snacks (0/1)
|
||||
// had_sweets -- aus cinema.ink via KV: Spieler hat Süßes gewählt (0/1)
|
||||
// fallbear -- temporär: Bär verliert Gleichgewicht beim Ausweichen
|
||||
// boop_success -- temporär: Einhorn-Nase boopen erfolgreich (0/1)
|
||||
//
|
||||
// Stub-Knoten (noch zu schreiben):
|
||||
// the_show, cbear, shadow_battle, apprentice, quitter,
|
||||
// her_portfolio, performance, practice, seeulater
|
||||
|
||||
VAR favor_nails = 0
|
||||
VAR past_it = false
|
||||
VAR snaks = 0
|
||||
VAR had_sweets = 0
|
||||
VAR fallbear = false
|
||||
VAR boop_success = false
|
||||
|
||||
-> f4r4
|
||||
|
||||
|
||||
== f4r4 ==
|
||||
|
||||
You push the door open to a few steps bringing you the level where the seats are. The theatre screen is lit in a faint pink that floods the dingy room with a pleasant atmosphere. The seats lie in shadow, but they look empty.
|
||||
|
||||
+ [Go to the screen] -> f4r4.to_the_screen
|
||||
+ [Sit down in the closest seat] -> f4r4.frontrow_to_show
|
||||
+ [Go to the back] -> f4r4.backseat
|
||||
|
||||
= to_the_screen
|
||||
You go through the aisle to the very front. You are so close to the screen here that your entire field of view is absorbed by this shade of pink, if you angle your head right.
|
||||
|
||||
+ [Go closer to the screen so your shadow falls on it]
|
||||
You take a few more steps until your silhouette emerges as a dark shape from the dark lower border of the screen.
|
||||
++ [Raise your arms] You raise your arms to see your shadow do the same.
|
||||
++ [Walk to the side] You walk a few steps to the side to see your shadow do the same.
|
||||
--
|
||||
Until the enormous shape of a wolf jumps at you from the side. You only see it come for a second before you flinch away.
|
||||
|
||||
But there is no impact, it is just another shadow. It looked very real out of the corner of your eye. On a second look, you see that it is the projection of masterfully interwoven fingers, cast from the back of the theatre.
|
||||
-> f4r4.first_duet
|
||||
+ [Turn back to the seats]
|
||||
But you turn away from it and to the seats again. From here you have a better view of the theatre and can see a shape, sitting in the elevated rows on the back half of the room.
|
||||
-
|
||||
A voice comes from there.
|
||||
|
||||
Her: OMG! I totally don't believe it? Is that an audience? OK. OK. OK, sit down. The show is about to start.
|
||||
++ [Take a seat]
|
||||
You sit down in the front row and look up to the screen.
|
||||
-> f4r4.the_show
|
||||
++ [Go to the person]
|
||||
You are more curious about this person than the show. So you decide to make your way up the dark rows of seats to get a better look.
|
||||
-> f4r4.the_performer
|
||||
|
||||
= frontrow_to_show
|
||||
You slump down in the closest seat. You are fairly far to the front. So you can lie your head down on the back of the seat to look up into the abyss of pink.
|
||||
|
||||
You have huffed out only one deep breath before the shadow of a giant hand rips the light and colour of the screen apart.
|
||||
|
||||
+ [I'm startled]
|
||||
You startle at that and turn around in your seat to look for the source of the shadow.
|
||||
|
||||
Against the glare of the projector you can just about make out two hands which are raised over the rows of seats.
|
||||
|
||||
You raise a hand against the bright light. But before your eyes can adjust to the dark, you hear a voice.
|
||||
|
||||
Her: Eyes front, please. Or you'll miss the show.
|
||||
|
||||
The voice has the twang of mock annoyance.
|
||||
++ [Turn to the screen] You comply and look to the screen again. -> f4r4.the_show
|
||||
++ [Go to investigate] You get up and go towards the back, where the figure is. -> f4r4.the_performer
|
||||
--
|
||||
+ [This is fine.]
|
||||
++ [I turn to look for the shadow maker]
|
||||
You turn around in your seat to look for the source of the shadow.
|
||||
|
||||
Against the glare of the projector you can just about make out two hands which are raised over the landscape of cushioned backrests.
|
||||
|
||||
You raise a hand against the bright light. But before your eyes can adjust to the dark, you hear a voice.
|
||||
|
||||
Her: Eyes front, please. Or you'll miss the show.
|
||||
|
||||
The voice has the twang of mock annoyance.
|
||||
+++ [Turn to the screen] You comply and look to the screen again. -> f4r4.the_show
|
||||
+++ [Go to investigate] You get up and go towards the back, where the figure is. -> f4r4.the_performer
|
||||
---
|
||||
++ [I keep looking at the screen]
|
||||
You keep your eyes on the screen unperturbed and follow the movements of the shadows with your eyes.
|
||||
-> f4r4.silent_show
|
||||
|
||||
= backseat
|
||||
When you are at the cinema, do you always prefer the seats at the very back? You make your way there now, automatically. You shimmy into the aisles and pick a seat. You sit down. It is comfortable. It is fine. You relax, with the whole room in view.
|
||||
-> f4r4.the_show
|
||||
|
||||
= the_performer
|
||||
Her: No. It's fine. You can sit down in the front as well. Just there will be fine.
|
||||
|
||||
+ [Continue to the back]
|
||||
~ favor_nails = favor_nails - 1
|
||||
You continue your way despite her protests.
|
||||
|
||||
Her: Just sit down! Urgh. Now I have to delay the performance.
|
||||
|
||||
You have reached the row where the figure sits and huffs unhappily at you. You can still only see their outline in the dark. Something about it is strange. Their head is oddly big. It has a weird shape, too.
|
||||
|
||||
Her: Fine, you can sit in the back row if you have to. No respect for the craft.
|
||||
|
||||
+ [Take a seat where you are] She sounds annoyed. You decide to obey and sit down in the middle row. -> f4r4.the_show
|
||||
- (just_sit_damnit)
|
||||
* [Who are you?]
|
||||
You: Who are you?
|
||||
|
||||
Her: Only the greatest visual artist you will ever meet. Not that you would be able to appreciate it, since you refuse to sit down for the performance to start.
|
||||
|
||||
++ [That's not really an answer.]
|
||||
~ favor_nails = favor_nails - 3
|
||||
You: That's not really an answer.
|
||||
|
||||
Her: Oh mahhai, unbelievable. Don't be such a pain in the ass just sit down or get out of my theatre.
|
||||
|
||||
+++ [What kind of performance?]
|
||||
~ favor_nails = favor_nails - 1
|
||||
You: What kind of performance?
|
||||
|
||||
Her: Are you for fudging real? Just sit down and find out?
|
||||
-> just_sit_damnit
|
||||
+++ [Can I sit with you?]
|
||||
Her: I need some space to perform. Just keep a little bit of a distance.
|
||||
++++ [Sit down across the aisle] You just sit down in the first seat across the aisle from her. -> f4r4.the_show
|
||||
++++ [Take a seat two places away from her]
|
||||
You shimmy into the aisle where she is and sit down with a double seat of space to her. A side glance in her direction subjects ruffled knit fabrics and jangling bracelets.
|
||||
-> f4r4.the_show
|
||||
++++ [Sit down in the back row]
|
||||
You'd actually prefer to sit in the back row. So you go and take your seat, your back against the wall, with the whole theatre in view.
|
||||
-> f4r4.the_show
|
||||
+++ [You just sit your ass down across the aisle from her]
|
||||
You just sit down in the first seat across the aisle from her.
|
||||
-> f4r4.the_show
|
||||
+++ [Sit down in the back row]
|
||||
You prefer to sit in the back row. So you go and take your seat, your back against the wall, with the whole theatre in view.
|
||||
-> f4r4.the_show
|
||||
--
|
||||
* [Can I sit with you?]
|
||||
~ favor_nails = favor_nails + 1
|
||||
You: Can I sit with you?
|
||||
|
||||
Her: Oh. Well, actually I need a bit of space to perform. But you can sit here if you want.
|
||||
|
||||
You can just make out a hand with long, pink acrylic nails and glittering rings gesturing to a place two seats away from her.
|
||||
|
||||
You shimmy into the aisle and take the seat. A side glance in her direction subjects ruffled knit fabrics and jangling bracelets.
|
||||
-> f4r4.the_show
|
||||
* [Sit down across the aisle from her]
|
||||
You sit down in the first seat across the aisle from her.
|
||||
-> f4r4.the_show
|
||||
* [Sit down in the back row]
|
||||
You prefer to sit in the back row. So you go and take your seat, your back against the wall, with the whole theatre in view.
|
||||
-> f4r4.the_show
|
||||
|
||||
= first_duet
|
||||
* [Try to see the one making the shadow]
|
||||
You turn, raising your hand against the light of the projector. But too late, the glare has already gotten into your eyes and you can't make out anything in the darkness of the room.
|
||||
|
||||
An amused voice comes out of the darkness.
|
||||
|
||||
Her: Watch out, here it comes again.
|
||||
|
||||
++ [Turn to the screen]
|
||||
Turn back towards the screen and see the wolf pace your shadow. Readying itself for the next attack.
|
||||
-> f4r4.first_duet
|
||||
++ [Go investigate]
|
||||
You start up the aisle between the seats.
|
||||
|
||||
A voice comes out of the darkness.
|
||||
|
||||
Her: Whahat? Don't tell me you didn't like that. That was A-Class theatre. You know I don't get an audience here often. Take a seat and I'll show you a real show.
|
||||
-> f4r4.the_performer
|
||||
* (bunny)[Make the shadow figure of a bunny, to run from the wolf]
|
||||
You position your hands to make the figure of a bunny.
|
||||
++ Its ears wiggle nervously. [] The ears of the wolf twitch in turn, with anticipation.
|
||||
++ It rubs its nose with its front paws in an attempt to be too cute to be eaten. [] But there is only a low hungry growl from the back of the theatre.
|
||||
--
|
||||
Your bunny decides to make a run for it.
|
||||
|
||||
The wolf jumps after it with graceful movements. It is quick and deadly.
|
||||
|
||||
++ [The bunny flees off screen]
|
||||
Your bunny flees to the side of the screen and jumps into the unlit void. Trusting that nonexistence will keep it safe from the jaws of the predator.
|
||||
-> f4r4.quitter
|
||||
++ [The bunny flees across the screen]
|
||||
Your bunny hops across the screen. You have to be fast and clever and change directions often to get away from the monster chasing you.
|
||||
--
|
||||
Soon you are short of breath and turn once more to see the wolf mid-air just above you. Maw wide, ready to consume you. The running is over. You only have a second to decide what to do.
|
||||
|
||||
++ [Accept your fate]
|
||||
So you accept your fate and cower the bunny down in a final primal reflex, before the wolf is on top of you and swallows you whole.
|
||||
|
||||
You are blacked out by the shape of it for a moment and can see the hands raised above the seats silhouetted against the rays of beamer light. Maybe it is just the perspective or something, but they seem gigantic.
|
||||
|
||||
Then the shadow of the wolf moves beside you again. The light punches you in the face and it wags its tail. A voice comes from the back of the room.
|
||||
|
||||
Her: Ahhh! That was SOOOOO FUN. More. More. More.
|
||||
|
||||
+++ [I think I need to catch my breath first.]
|
||||
You: I think I need to catch my breath first.
|
||||
|
||||
Her: Hm. Okie doke. You wanna see one of my specialities?
|
||||
|
||||
++++ [Sure.] You: Sure.
|
||||
You take your front row seat and look up to the screen.
|
||||
-> f4r4.the_show
|
||||
++++ [Who are you?]
|
||||
You: Who are you?
|
||||
|
||||
Her: I'm the baddest bitch alive. Now sit your ass down and let me put on a show.
|
||||
----
|
||||
-> f4r4.the_show
|
||||
++++ [Sit] You indulge her and sit. -> f4r4.the_show
|
||||
++++ [Go up to her] You make your way up the aisles to her. -> f4r4.the_performer
|
||||
+++ [OK. But next time I won't be such easy prey.]
|
||||
You: OK. But next time I won't be such easy prey.
|
||||
|
||||
Her: Uh. You want a shadow battle? Bring it oooooon.
|
||||
-> f4r4.shadow_battle
|
||||
---
|
||||
++ (cheat)[Turn into a crocodile]
|
||||
So you dissolve the bunny to spread your arms wide. Creating the long jaws of a crocodile. It is a desperate move and unexpected enough that the wolf can't stop before plunging dead first into the trap you just created. You snap your arms together.
|
||||
|
||||
The wolf's whine becomes a whiny voice.
|
||||
|
||||
Her: NOOOOOOO. That's not fair. Not fair. Not fair.
|
||||
|
||||
+++ [Says who?]
|
||||
You: Says who?
|
||||
|
||||
Her: Says ME! You can't just change the rules like that.
|
||||
|
||||
You: I wasn't aware there were any rules.
|
||||
|
||||
Her: But I wanted to eat you!
|
||||
|
||||
++++ [You can eat my ass.]
|
||||
She laughs.
|
||||
|
||||
Her: EW! I don't even know you.
|
||||
|
||||
+++++ [We can change that.]
|
||||
~ favor_nails = favor_nails + 1
|
||||
You: We can change that.
|
||||
|
||||
Her: WOW. You coming on strong. I can't even tell if you're cute from up here. We will have to meet somewhere where it is light, some time.
|
||||
|
||||
++++++ [It's a date.] You: It's a date.
|
||||
She giggles.
|
||||
------
|
||||
Her: I wanna practice some more of my figures. Do you wanna watch?
|
||||
-> f4r4.practice
|
||||
++++++ [How about now?] You: How about now?
|
||||
------
|
||||
Her: I wanna practice some more of my figures. Do you wanna watch?
|
||||
-> f4r4.practice
|
||||
+++++ [I don't know you either.]
|
||||
You: I don't know you either.
|
||||
|
||||
Her: You know my work. So you already know I'm fab as fuck.
|
||||
|
||||
++++++ [True.] You: True.
|
||||
Her: Do you wanna see some more?
|
||||
+++++++ [Sure.] You: Sure. -> f4r4.the_show
|
||||
+++++++ [I would actually like to get to know you.]
|
||||
You: I would actually like to get to know you.
|
||||
|
||||
Her: HHHM. I actually wanna practice some more figures. But we can grab snacks after.
|
||||
|
||||
++++++++ [Can I watch you practice?]
|
||||
You: Can I watch you practice?
|
||||
Her: Sure.
|
||||
-> f4r4.practice
|
||||
++++++++ [Should I come back later to pick you up?]
|
||||
You: Should I come back later to pick you up?
|
||||
Her: If you want.
|
||||
--------
|
||||
You: OK, I will see you in a bit.
|
||||
-> f4r4.seeulater
|
||||
-------
|
||||
Her: Nice. What do you want to see? My portfolio or a Full Performance?
|
||||
+++++++ [Portfolio] You: Your portfolio? Now I'm curious. -> f4r4.her_portfolio
|
||||
+++++++ [Performance] You: I would like to see a performance. -> f4r4.performance
|
||||
-----
|
||||
++++ [No don't eat me. I can get you popcorn instead.]
|
||||
Her: NAWWW! OK. Fair is fair.
|
||||
+++ [Sorry.]
|
||||
You: Sorry. But I don't want to die today.
|
||||
|
||||
Her: Well, you shouldn't have been a bunny then, gorgeous. A wolf has to eat.
|
||||
* [Make the shadow figure of a bear, to confront the wolf]
|
||||
You make a bear...
|
||||
|
||||
Do you know how to make a bear shadow figure?
|
||||
|
||||
++ [YES]
|
||||
OK. You make a bear. But due to your position relative to the screen it is too tiny to confront the wolf.
|
||||
++ [NO] You don't make a bear. -> f4r4.first_duet
|
||||
--
|
||||
The wolf is watching you impatiently.
|
||||
++ [Make the bear bigger] You decide to make a bigger bear.
|
||||
++ [Do something else] -> f4r4.first_duet
|
||||
--
|
||||
But the beast on the screen beside you has clearly had enough and is readying its next attack.
|
||||
++ [Make your way to the back of the room]
|
||||
You can't compete from down here. So you start to make your way up the aisles to some high ground.
|
||||
|
||||
As soon as you step out of the light of the screen a voice comes from the back.
|
||||
|
||||
Her: No! Where are you going? I thought we were going to play?
|
||||
|
||||
You squint up and into the dark and see two abnormally large hands raised over the seats and backlit by the projector.
|
||||
|
||||
+++ [I'm just coming up to project better.]
|
||||
You: I'm just coming up to project better.
|
||||
|
||||
Her: I think you'll need a little more than that to get on my level.
|
||||
|
||||
++++ [I'm sure.] You: I'm sure.
|
||||
Her: You can still show me what you've got, though. Come here.
|
||||
-> f4r4.deathmatch
|
||||
++++ [We will see.] You: We will see.
|
||||
Her: OK. A challenge? IT. IS. ON.
|
||||
-> f4r4.deathmatch
|
||||
+++ [Who are you?]
|
||||
You: Who are you?
|
||||
|
||||
Her: Ähm, Hellooho? I'm just the greatest show master in this place. Maybe in the world. Your pleasure.
|
||||
|
||||
++++ [How was I supposed to know that?]
|
||||
~ favor_nails = favor_nails - 1
|
||||
|
||||
Her: OK I forgive you, that you have no taste. I'll even put on a show, yes? Sit down.
|
||||
|
||||
+++++ [Sit] You sit in the seat nearest to you. -> f4r4.the_show
|
||||
+++++ [Go up to her] You continue to go up to her. -> f4r4.the_performer
|
||||
+++++ [I thought we were going to make a wolf and bear death match?]
|
||||
You: I thought we were going to make a wolf and bear death match?
|
||||
Her: Ya. OK. You can come here so I can obliterate you.
|
||||
-> f4r4.deathmatch
|
||||
++++ [I think they have failed to put up a poster for you downstairs.]
|
||||
Her: What do you expect? There is no fudging respect for real art in this place. So lame. Sit down and I'm gonna give you the real off the hook showy show stuff.
|
||||
|
||||
+++++ [Sit] You sit in the seat nearest to you. -> f4r4.the_show
|
||||
+++++ [Go up to her] You continue to go up to her. -> f4r4.the_performer
|
||||
+++++ [I thought we were going to make a wolf and bear death match?]
|
||||
You: I thought we were going to make a wolf and bear death match?
|
||||
Her: Ya. OK. Come here and bring it on then.
|
||||
-> f4r4.deathmatch
|
||||
++ [Run to the back of the room]
|
||||
You are just a tiny thing down here, without a fighting chance. You bolt up the aisle to get yourself some size.
|
||||
|
||||
There is a startled squeak from the back of the room.
|
||||
|
||||
+++ [Ignore it]
|
||||
You charge on. To a spot where you will be big enough. You jump on to a seat for more high ground and make a much better sized bear.
|
||||
-> f4r4.bear
|
||||
+++ [Look up]
|
||||
You look up and slow down. Squinting into the darkness you can see a figure in one of the raised rows. Your opponent.
|
||||
|
||||
You arrive at a spot where you will be big enough. You jump on to a seat for more high ground and make a much better sized bear.
|
||||
-> f4r4.bear
|
||||
++ [Clamber over the seats like a crazy person]
|
||||
You are just a tiny thing down here, begging to be eaten alive. You jump and clamber over the backs of the seats to get yourself some size.
|
||||
|
||||
There is laughter from the back of the theatre.
|
||||
|
||||
Her: HAHAHA what are you doing? The wolf is not actually gonna eat you, you know?
|
||||
|
||||
+++ [Continue] You don't stop. Only slow down a little to look up.
|
||||
+++ [Halt] You stop to look up.
|
||||
---
|
||||
You can see two giant backlit hands. Forming a wolf.
|
||||
|
||||
+++ [Best to be sure it won't eat me.]
|
||||
Her: Jup. OK?
|
||||
|
||||
You have made enough ground and are standing up on a seat now. You make a bear that is much better sized.
|
||||
|
||||
You: Jup. Much better. Thanks. Let's GO.
|
||||
-> f4r4.bear
|
||||
+++ [I was just trying to get some high ground for a counterattack.]
|
||||
Her: Oh! You think you can challenge me? Bring it on.
|
||||
|
||||
You have made enough ground and are standing up on a seat now. You make a bear that is much better sized.
|
||||
|
||||
You: Let's go.
|
||||
-> f4r4.bear
|
||||
* [Mime your bloody death from the wolf attack]
|
||||
Your reaction time is a bit off. But it is not too late yet to mime your bloody murder by the claws of the beast. You put some theatricality into your death scene.
|
||||
|
||||
Your reward is amused laughter from the back of the theatre as the wolf dissolves into hands.
|
||||
|
||||
++ [Take a bow]
|
||||
You take a theatrical bow. Which is rewarded with some fluttery applause.
|
||||
++ [Try to see the shadow maker]
|
||||
You raise your hand and squint against the beamer's glare to see your attacker. You can only just about see two very large hands.
|
||||
--
|
||||
Her: Do another one. Do another one!
|
||||
|
||||
++ [Ok. Attack me again.] You: Ok. Attack me again. -> f4r4.unicorn
|
||||
++ [Make your own shadow puppet]
|
||||
You decide to try your own hand at shadow puppeteering.
|
||||
|
||||
Do you even know how to do that?
|
||||
|
||||
+++ [Yes]
|
||||
Alright. You make a...
|
||||
++++ [Wolf] A small wolf.
|
||||
++++ (bunny_puppet)[Bunny] A bunny.
|
||||
++++ [Butterfly] A butterfly.
|
||||
++++ [Spider] A scuttling spider.
|
||||
----
|
||||
Her: Sorry. Not sorry. I'm not impressed. But it is also veeeerrrrryyyy hard to impress me.
|
||||
+++ [No]
|
||||
You don't really know how but you can still make a shadow figure with your full body.
|
||||
|
||||
So you make...
|
||||
++++ [A worm] A worm, by raising your hand over your head and making a wave with your whole body.
|
||||
++++ (kitty)[Yourself cat ears]
|
||||
Your hands into cat ears. They turn and twitch.
|
||||
+++++ [Miou.]
|
||||
~ favor_nails = favor_nails + 1
|
||||
You: Miou.
|
||||
Her: Naw
|
||||
++++ [Yourself antlers] Your hands into majestic antlers protruding from your head.
|
||||
++++ [A T-pose] A T-pose. You had other ideas, but I guess you like being trash.
|
||||
Her: Hmmm. OK? What are you supposed to be?
|
||||
----
|
||||
---
|
||||
Her: I possibly see the tiniest bit of talent here. Maybe if you work at it, we could make a show together. If you try to upstage me, you are out, though.
|
||||
-> f4r4.apprentice
|
||||
* [Try to replicate the wolf]
|
||||
You try to figure out how exactly the shape of the wolf is made and to interlock your own fingers to replicate the effect.
|
||||
|
||||
++ [Observe] You observe...
|
||||
--
|
||||
++ [Build the finger form] Arrange the fingers...
|
||||
--
|
||||
++ [Project]
|
||||
// TODO: per Shuffle zufällig zwischen "passable" und "ugly" wechseln.
|
||||
// Inklecate unterstützt keine mehrzeiligen {~}-Sequenzen -- braucht
|
||||
// VAR wolf_ugly + { shuffle: - ... - ... }-Syntax oder zwei Pfade.
|
||||
And... you create a passable attempt. Much smaller and a little crooked, maybe.
|
||||
--
|
||||
|
||||
= unicorn
|
||||
Her: OK. OK. Do this one.
|
||||
|
||||
The hands resolve themselves into the shape of a unicorn.
|
||||
|
||||
+ [Try to pacify the beast]
|
||||
You hold out your hand in an attempt to pacify the beast.
|
||||
|
||||
The unicorn huffs and scuffs a hoof against the floor.
|
||||
|
||||
++ [Shhh. Easy. It's fine.]
|
||||
The unicorn doesn't seem convinced. It shakes its head in irritation.
|
||||
|
||||
+++ {snaks} [Present the snacks you picked up as a peace offering]
|
||||
You: Here I have something for you.
|
||||
|
||||
You hold out the sweets you picked up earlier.
|
||||
|
||||
Her: OMG! You have snacks? I want some. I want some! Get up here!
|
||||
|
||||
She sounds so excited, you have to oblige.
|
||||
-> f4r4.snacksharing
|
||||
+++ [Persist in being soothing]
|
||||
You still persist in making soothing noises and hold your hands up.
|
||||
|
||||
But it doesn't help.
|
||||
+++ [Prepare for an attack]
|
||||
You step back a little and square your stance for the next attack.
|
||||
---
|
||||
++ [Prepare for an attack]
|
||||
You step back a little and square your stance for the next attack.
|
||||
--
|
||||
+ [Prepare for the attack]
|
||||
You square your stance in anticipation for the next attack.
|
||||
-
|
||||
It scuffs its hoofs, rears up and charges you.
|
||||
|
||||
+ [Dodge]
|
||||
In the last moment you perform a jump-roll to the side.
|
||||
|
||||
Her: Hey! You can't dodge-roll in two dimensions!
|
||||
|
||||
++ [Worth a try!]
|
||||
You: Worth a try!
|
||||
|
||||
Now you are in the position of squaring off with the unicorn again. It charges once more.
|
||||
|
||||
+++ [Dodge]
|
||||
You dodge-roll again and hear a frustrated shout from the back of the theatre.
|
||||
|
||||
As you get back up again you see the shadow unicorn is already on top of you. Trampling you to death instead.
|
||||
|
||||
Her: There, serves you right, you jumping Jack. Are you trying to steal my scene? You know, for a moment there I thought this was kind of fun. Tell me: what are your acting references?
|
||||
++++ [Get impaled]
|
||||
---
|
||||
---
|
||||
++ [Oh Yeah? Watch me!]
|
||||
You: OH Yeah? Watch me!
|
||||
|
||||
You proceed to dodge-roll across the stage like a hedgehog on speed.
|
||||
|
||||
Between rolls you see the bewildered unicorn watch you from the side.
|
||||
|
||||
+++ [You could probably surprise it and boop it on the nose]
|
||||
~ boop_success = RANDOM(0, 1) == 1
|
||||
{boop_success:
|
||||
You jump-roll towards it, get up and manage a quick boop on its nose before dodge-rolling away again.
|
||||
- else:
|
||||
You jump-roll towards it. But unfortunately it wasn't that distracted and before you can boop it, it has lowered its horn and is making you into crazy-person-shashlik.
|
||||
}
|
||||
{not boop_success: -> after_boop}
|
||||
++++[Celebrate your victory]
|
||||
You stop to raise your hands up into the air to celebrate your victory. Meanwhile there is sarcastic clapping from the backseats.
|
||||
|
||||
Her: wow. OK. Great show, I guess. Very... acrobatic
|
||||
---- (after_boop)
|
||||
+++ [Continue rolling]
|
||||
You can't stop now. So you dodge-roll until you become nauseous and you are out of breath.
|
||||
|
||||
You just collapse on the floor and take some deep breaths. It's getting warm in the beamer light.
|
||||
|
||||
On the screen beside you, you can see the shadow unicorn come up to you cautiously and sniff you.
|
||||
|
||||
++++ [Raise a hand]
|
||||
You raise a hand to show up on the screen. The unicorn raises its head out of reach again.
|
||||
|
||||
Her: OH Good. You are not dead.
|
||||
++++ [Sit up]
|
||||
You sit up and the unicorn steps back from your shadow. Satisfied you are ok.
|
||||
|
||||
Her: Oh Good. You are not dead.
|
||||
----
|
||||
---
|
||||
++ [Just wanted to make it a bit more interesting.]
|
||||
You: Just wanted to make it a bit more interesting.
|
||||
|
||||
Her: Convince me of your acting talent first. We can workshop it once I have decided you are worth it to share a stage with me.
|
||||
|
||||
The unicorn charges again.
|
||||
|
||||
+++ [Dodge]
|
||||
You dodge-roll again.
|
||||
|
||||
Her: HEY!
|
||||
|
||||
As you get back up again you see the shadow unicorn is already on top of you. Trampling you to death.
|
||||
|
||||
Her: There, serves you right, you jumping Jack. Are you trying to steal my scene? Tell me: what are your acting references?
|
||||
++++ [Get impaled]
|
||||
---
|
||||
--
|
||||
You try even harder for your second death scene. Now that you know you have an appreciative scene partner and audience. You cheat to the side of the screen to see the unicorn and its movement. It has got you through the solar plexus and you convulse with the jerks of its head. When it finally pulls its horn up and out, you go with the movement and fall face first to the floor. You just about manage to make it a stage fall and not knock your teeth out on the thin scratchy carpet.
|
||||
|
||||
You stay on the floor until you hear clapping.
|
||||
|
||||
Her: I have to say, that wasn't bad. I mean I clearly carried the scene. But it really makes the action more lively with an acting partner. Tell me: what are your references?
|
||||
|
||||
* [I did some acting in school.] You: I did some acting in school.
|
||||
Her: Wouldn't go boasting with that.
|
||||
* [I actually have a degree in acting.] You: I actually have a degree in acting.
|
||||
Her: Well, there is no such thing as a legitimate art adjudication, you know.
|
||||
* [I am just a natural.] You: I am just a natural.
|
||||
Her: Ha. Sure thing sweety. Don't talk about a natural when I'm at the table.
|
||||
-
|
||||
Her: I can accept this as a first audition, I think. I know I'm hard to impress. The competition is very absent, though. But I think I really saw a bit of style in there. Deep, deep down. Maybe I can make a passable partner of you. Yas. I totally see the vision.
|
||||
-> f4r4.snacksharing
|
||||
|
||||
= snacksharing
|
||||
You go up the aisle towards the dark silhouette. It is too dark to make much out. When you are a few rows away you see the form move. You see arms and shoulders and above them something which is not the shape of a head.
|
||||
|
||||
When you are at her row, she is just stepping out onto the aisle. Her high-heeled shoes make no sound on the carpet. She straightens her frilly dress and puts one of the straps that slipped off her shoulder back in its rightful place.
|
||||
|
||||
When you look up to her face, your eyes adjust enough for you to see. A mess of long fingers unfurls itself into the clear shape of two gigantic but delicate hands, bedecked in rings and acrylic nails, growing from a slender neck with a y2K choker.
|
||||
|
||||
+ [I think she is cool.]
|
||||
She is very cool indeed.
|
||||
|
||||
You hold out the snacks.
|
||||
|
||||
Her: Yummy. Oh maaaan it's been tooooo long.
|
||||
|
||||
She grabs the package from your hand.
|
||||
|
||||
Her: I don't know. I guess I've just been so focused on perfecting my performance lately. I guess I haven't really had any time to get out. And walking up and down all the stairs. Urgh. Hate that.
|
||||
|
||||
One of her hands faces to the front and forms a snout with three fingers to the front while the Pinky and index finger perk up. Her makeshift foxface looks down to the package of snacks.
|
||||
|
||||
{had_sweets:
|
||||
Her: MMHHhhh Yum! Sweets. I always need something sweet.
|
||||
|
||||
She tears at the plastic wrapping and tosses a piece up into her hand which folds into a fist briefly. A second later, when her hand resumes the foxface the piece of candy is gone.
|
||||
|
||||
Her: Hm. OK. Ordinary candy.
|
||||
|
||||
She studies the package again.
|
||||
|
||||
Her: Didn't even know they had those here. Sweet. But that's it. You should try some of the local sorts. They pack a little more ZING.
|
||||
- else:
|
||||
Her: Huh. Salty snacks. OK I guess. Sorry, I normally prefer sweets. But I'd still like to try some.
|
||||
|
||||
She tears at the plastic wrapping and tosses a piece up into her hand which folds into a fist briefly. A second later, when her hand resumes the foxface the snack is gone.
|
||||
|
||||
Her: Hm? Is it just a normal snack?
|
||||
|
||||
She studies the package again.
|
||||
|
||||
Her: Didn't even know they had those here. You should try some of the local stuff. It's a little more interesting.
|
||||
}
|
||||
+ [I think she is scary.]
|
||||
You are a little intimidated.
|
||||
|
||||
Her: What? Don't worry, I don't bite. Well, nah you've seen the worst of my bite down there during the show.
|
||||
|
||||
One of her hands faces to the front and forms a snout with three fingers to the front while the Pinky and index finger perk up. She opens and closes the fingers a little as she talks. It looks like someone comically ventriloquising a hand puppet. It doesn't look genuine.
|
||||
|
||||
Her: Or maybe I will bite if you keep these sweets you promised me.
|
||||
|
||||
You raise your hand with the package and she snatches it from you. Her makeshift foxface looks down at the bag of snacks.
|
||||
|
||||
{had_sweets:
|
||||
Her: MMHHhhh Yum! Sweets. I always need something sweet.
|
||||
|
||||
She tears at the plastic wrapping and tosses a piece up into her hand which folds into a fist briefly. A second later, when her hand resumes the foxface the piece of candy is gone.
|
||||
|
||||
Her: Hm. OK. Ordinary candy.
|
||||
|
||||
She studies the package again.
|
||||
|
||||
Her: Didn't even know they had those here. Sweet. But that's it. You should try some of the local sorts. They pack a little more ZING.
|
||||
- else:
|
||||
Her: Huh. Salty snacks. OK I guess. I normally prefer sweets. But I guess this is a total emergency situation.
|
||||
|
||||
She tears at the plastic wrapping and tosses a piece up into her hand which folds into a fist briefly. A second later, when her hand resumes the foxface the snack is gone.
|
||||
|
||||
Her: Hm? Is it just a normal snack?
|
||||
|
||||
She studies the package again.
|
||||
|
||||
Her: Didn't even know they had those here. You should try some of the local stuff. It's a little more interesting.
|
||||
}
|
||||
-
|
||||
-> END
|
||||
|
||||
= deathmatch
|
||||
Her: If you are a grizzly bear, then I will be a dragon.
|
||||
|
||||
You: Then I will be the winter wind. Blowing out your flame and freezing you.
|
||||
|
||||
Her: Then I will be the ocean. Then you could never blow away.
|
||||
|
||||
You: My wind will be waves then. Sitting on top of you. Disturbing your depths.
|
||||
|
||||
Her: I will be the moon then. So you will have to bow to my every will.
|
||||
|
||||
+ [Flirt... yes please]
|
||||
~ favor_nails = favor_nails + 1
|
||||
+ [I will be the earth so you can only be as bright as I want you to be.]
|
||||
|
||||
-
|
||||
Her: Then I am the sun. So only I can decide where the light is.
|
||||
|
||||
The screen blacks out except for a small globe of light.
|
||||
-> END
|
||||
|
||||
= bear
|
||||
The wolf is snarling and fierce as before, but its tail twitches a little. You have intimidated it, by the looks of it.
|
||||
|
||||
Your bear is on its hind legs and looming tall.
|
||||
|
||||
You must never underestimate how dangerous a desperate animal is.
|
||||
|
||||
+ [Check your stance.]
|
||||
You are standing a bit unsafely on the chairs. So, you take your eyes off your opponent for a second to check your stance.
|
||||
|
||||
As you shift your right foot into a safer position the projector light on your cheek is disturbed. You can see out of the corner of your eye how the shadow grazes you. As you snap your attention up to the screen you see the wolf on your bear's hump. Biting down savagely.
|
||||
|
||||
You don't even have to think about it, as you raise your paws and beat at the side of the wolf. The force of it is enough to send it flying off of you and skidding to the edge of the screen. It is up on its feet again in seconds.
|
||||
|
||||
++ [Roar at it.]
|
||||
Your bear is not very hurt but furious. You send out a roar. A warning and a threat.
|
||||
|
||||
But the wolf will not back down. It charges again.
|
||||
++ [Run to it to attack.]
|
||||
You have no time to lose either, so you jump from the seat. The bear gallops towards the wolf.
|
||||
--
|
||||
++ [Bite] The bear is quick on four feet and opens its massive jaws to welcome the wolf.
|
||||
++ [Thrash] The bear stands tall and swipes a paw at the beast.
|
||||
--
|
||||
The wolf has left itself enough of an opening to jump back in time.
|
||||
|
||||
++ [Bite] You bite. But it jumps over you.
|
||||
++ [Thrash] You thrash. But it jumps around you.
|
||||
--
|
||||
You try a few more attacks. But the wolf is careful and nimble now.
|
||||
|
||||
It is baiting you. Cunning. You can already start to feel the strength of your attacks receding.
|
||||
|
||||
+ [Start your first attack]
|
||||
You are ready.
|
||||
|
||||
++ [Swipe a paw at the wolf]
|
||||
As the wolf jumps toward you the bear stands tall and swipes a massive paw down.
|
||||
|
||||
The wolf is quick but not quick enough to escape the attack completely. You catch it across its back and its jowls as it jumps away again.
|
||||
++ [Bite at the wolf.]
|
||||
The wolf jumps forward and the bear comes down to all four legs to bite at it.
|
||||
|
||||
The wolf is quick and jumps back from the bear's teeth, which scrape its back but don't manage the catch. A pained howling is your only reward.
|
||||
--
|
||||
The wolf paces for a moment.
|
||||
|
||||
++ [Wait] You wait to see its next move.
|
||||
Then it jumps forward again, jaws snapping.
|
||||
|
||||
+++ [Bite] The bear snaps its jaws right back. But the wolf has left itself enough of an opening to jump back in time.
|
||||
+++ [Thrash] But the bear is ready and strikes. The wolf has left itself enough of an opening to jump back in time.
|
||||
+++ [Dodge]
|
||||
~ fallbear = RANDOM(0, 1) == 1
|
||||
{fallbear:
|
||||
You only have to take a step back to be out of reach. But it only takes a step to destabilise you. And you fall.
|
||||
- else:
|
||||
It is slower so, the bear can just step back out of reach.
|
||||
}
|
||||
{not fallbear: -> after_dodge_inner}
|
||||
You can mostly brake your fall by catching the seats. You hang awkwardly in the row.
|
||||
|
||||
++++ [Admit defeat]
|
||||
You: Well played.
|
||||
|
||||
Her: Are you giving up?
|
||||
|
||||
You: What can I say. You got me.
|
||||
|
||||
You disentangle yourself from the seats. The wolf meanwhile resolves itself into a set of triumphant hands.
|
||||
|
||||
Her: Haha. Yes I did. But don't be embarrassed, I can tell you! You totally never stood a chance. I am the master of the craft. You wanna see some of my other specialities?
|
||||
|
||||
+++++ [Yes] You: Yes -> f4r4.her_portfolio
|
||||
++++ [Get back up]
|
||||
You quickly disentangle yourself from your compromised position. The wolf is already sniffing interestedly at the spot where you disappeared. Hungry for its kill.
|
||||
|
||||
++++ [Turn into a crocodile to ambush it]
|
||||
It is right above you now. An easy kill for an ambush predator. Your arms become the strong, crushing maw of a crocodile in a second. Jumping up as if from the watering hole. Swallowing the front half of the wolf in one bite. There isn't even enough time for it to scream in pain. You have become a swift and merciless death.
|
||||
|
||||
You slide back to the depths. Drawing the carcass of the wolf along.
|
||||
|
||||
Her: That was sooooooo not fair!
|
||||
|
||||
She sounds a little out of breath.
|
||||
|
||||
You: Well nature isn't fair.
|
||||
|
||||
Her: You shape shifted. That has nothing to do with nature.
|
||||
|
||||
You: It's called a plot twist.
|
||||
|
||||
Her: Ya a bad one. And what even happened to the bear? This isn't very narratively satisfying.
|
||||
|
||||
You: I'm sure the crocodile is satisfied.
|
||||
|
||||
Her: Oh my god. Just because you wanted to win. This makes no sense whatsoever!? If you could be bothered to stick to a storyline I would have destroyed you.
|
||||
|
||||
+++++ [OK. I'm sorry.]
|
||||
Her: Ya u better be. Now sit your ass down and I'm gonna show you how it's done.
|
||||
-> f4r4.the_show
|
||||
+++++ [You might have. If you were more flexible.]
|
||||
Her: Ah!? What did you just say? You can accuse me of many things but this is just slander! How dare you.
|
||||
-----
|
||||
---- (after_dodge_inner)
|
||||
---
|
||||
++ [Attack]
|
||||
But yours will be the next move.
|
||||
|
||||
+++ [Bite] The bear comes forward to snap its jaws. But the wolf has left itself enough of an opening to jump back in time.
|
||||
+++ [Thrash] The bear is ready and strikes. The wolf has left itself enough of an opening to jump back in time.
|
||||
---
|
||||
--
|
||||
You try a few more attacks. But the wolf is careful and nimble now.
|
||||
|
||||
++ [Bite] You bite. But it jumps over you.
|
||||
++ [Thrash] You thrash. But it jumps around you.
|
||||
--
|
||||
It is baiting you. Cunning. You can already start to feel the strength of your attacks receding.
|
||||
|
||||
= cbear
|
||||
// TODO: Gegenangriff-Bär -- Bär greift aus erhöhter Position an
|
||||
-> END
|
||||
|
||||
= the_show
|
||||
// TODO: Die eigentliche Vorstellung -- Nails' Schattentheater
|
||||
// Übergang in silent_show (stilles Beobachten) oder interaktiv
|
||||
-> silent_show
|
||||
|
||||
= silent_show
|
||||
A second shadowy hand joined the first. They wrap around each other and devolve into a mass of darkness with pins of light, shining between the bends of fingers which you no longer can tell apart or identify.
|
||||
|
||||
The splotch bulges and warps. Forming shapes you almost recognise. Organic shapes. No symmetry or clean edges.
|
||||
|
||||
Does your brain try to identify and categorise the shapes?
|
||||
|
||||
Do you see the pattern formed by projector light in the middle? Is it the fur pattern of an imaginary animal?
|
||||
|
||||
Do you see landscapes or stars?
|
||||
|
||||
The profiles of faces you know but can't place crown the edges of the shadow. There and gone in a moment.
|
||||
|
||||
Are you trying to figure out if the curtains are blue? There are no curtains. And if there were it would be rude to figure out the colour. Don't be distracted. Focus on the show. Someone is performing for you here.
|
||||
|
||||
The ink on the screen devolves itself into a shape your brain can grasp easily now. It is a Swan. Swimming on the water. It is perfect. Wings folded. The body smooth as a pebble. The neck long and arcing, bending up and around and down.
|
||||
|
||||
It dips its head into the water, gracefully. Kissing the ripples of the surface demurely before pulling back and shaking off water.
|
||||
|
||||
-> END
|
||||
|
||||
= shadow_battle
|
||||
// TODO: Schattenkampf -- Nails' wolf vs. Spieler's bear, freie Wahl
|
||||
-> END
|
||||
|
||||
= apprentice
|
||||
// TODO: Nails nimmt Spieler als Lehrling an
|
||||
-> END
|
||||
|
||||
= quitter
|
||||
// TODO: Hase flüchtet vom Bildschirm -- feige Niederlage
|
||||
-> END
|
||||
|
||||
= her_portfolio
|
||||
// TODO: Nails zeigt ihr Shadow-Portfolio
|
||||
-> END
|
||||
|
||||
= performance
|
||||
// TODO: Nails' volle Vorstellung
|
||||
-> END
|
||||
|
||||
= practice
|
||||
// TODO: Nails übt neue Figuren, Spieler schaut zu
|
||||
-> END
|
||||
|
||||
= seeulater
|
||||
// TODO: Verabredung für später -- Spieler kommt nochmal
|
||||
-> END
|
||||
@@ -0,0 +1,8 @@
|
||||
VAR cube_clicks = 0
|
||||
|
||||
Du hast den Würfel {cube_clicks} mal berührt.
|
||||
|
||||
{ cube_clicks >= 3:
|
||||
Er fühlt sich langsam abgenutzt an.
|
||||
}
|
||||
-> END
|
||||
@@ -0,0 +1 @@
|
||||
{"inkVersion":21,"root":[["^Du hast den Würfel ","ev",{"VAR?":"cube_clicks"},"out","/ev","^ mal berührt.","\n","ev",{"VAR?":"cube_clicks"},3,">=","/ev",[{"->":".^.b","c":true},{"b":["\n","^Er fühlt sich langsam abgenutzt an.","\n",{"->":"0.13"},null]}],"nop","\n","end",["done",{"#n":"g-0"}],null],"done",{"global decl":["ev",0,{"VAR=":"cube_clicks"},"/ev","end",null]}],"listDefs":{}}
|
||||
@@ -0,0 +1,46 @@
|
||||
VAR mushrooms_collected = 0
|
||||
VAR quest_mushroom_active = false
|
||||
VAR quest_mushroom_done = false
|
||||
|
||||
{
|
||||
- quest_mushroom_done:
|
||||
# color whisper
|
||||
Danke nochmal für die Pilze. Das Rezept ist fast fertig.
|
||||
-> ende
|
||||
|
||||
- quest_mushroom_active && mushrooms_collected >= 10:
|
||||
# color whisper
|
||||
Du hast {mushrooms_collected} Pilze dabei -- mehr als genug!
|
||||
* [Hier, nimm sie.]
|
||||
~ quest_mushroom_done = true
|
||||
# color whisper
|
||||
Wunderbar, vielen Dank! Das wird ein gutes Süppchen.
|
||||
-> ende
|
||||
* [Lass mich noch ein paar behalten.]
|
||||
# color whisper
|
||||
Wie du willst. Komm wieder, wenn du sie loswerden möchtest.
|
||||
-> ende
|
||||
|
||||
- quest_mushroom_active:
|
||||
# color whisper
|
||||
Du hast schon {mushrooms_collected} von 10 Pilzen. Mach ruhig weiter.
|
||||
-> ende
|
||||
|
||||
- else:
|
||||
# color whisper
|
||||
TEST1 Hallo! Ich bräuchte 10 Pilze für ein Rezept. Hättest du Lust, welche zu sammeln?
|
||||
* [Klar, ich helfe.]
|
||||
~ quest_mushroom_active = true
|
||||
# color whisper
|
||||
Danke! Ich warte hier.
|
||||
-> ende
|
||||
* [Vielleicht später.]
|
||||
# color whisper
|
||||
Schade. Komm gerne nochmal vorbei.
|
||||
-> ende
|
||||
}
|
||||
|
||||
= ende
|
||||
* [Bis dann]
|
||||
# person_farewell
|
||||
-> END
|
||||
@@ -0,0 +1 @@
|
||||
{"inkVersion":21,"root":[[["ev",{"VAR?":"quest_mushroom_done"},"/ev",{"->":".^.b","c":true},{"b":["\n","#","^color whisper","/#","^Danke nochmal für die Pilze. Das Rezept ist fast fertig.","\n",{"->":"ende"},{"->":"0.4"},null]}],["ev",{"VAR?":"quest_mushroom_active"},{"VAR?":"mushrooms_collected"},10,">=","&&","/ev",{"->":".^.b","c":true},{"b":["\n","#","^color whisper","/#","^Du hast ","ev",{"VAR?":"mushrooms_collected"},"out","/ev","^ Pilze dabei -- mehr als genug!","\n","ev","str","^Hier, nimm sie.","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^Lass mich noch ein paar behalten.","/str","/ev",{"*":".^.c-1","flg":20},{"->":"0.4"},{"c-0":["\n","ev",true,"/ev",{"VAR=":"quest_mushroom_done","re":true},"#","^color whisper","/#","^Wunderbar, vielen Dank! Das wird ein gutes Süppchen.","\n",{"->":"ende"},{"#f":5}],"c-1":["\n","#","^color whisper","/#","^Wie du willst. Komm wieder, wenn du sie loswerden möchtest.","\n",{"->":"ende"},{"#f":5}]}]}],["ev",{"VAR?":"quest_mushroom_active"},"/ev",{"->":".^.b","c":true},{"b":["\n","#","^color whisper","/#","^Du hast schon ","ev",{"VAR?":"mushrooms_collected"},"out","/ev","^ von 10 Pilzen. Mach ruhig weiter.","\n",{"->":"ende"},{"->":"0.4"},null]}],[{"->":".^.b"},{"b":["\n","#","^color whisper","/#","^TEST1 Hallo! Ich bräuchte 10 Pilze für ein Rezept. Hättest du Lust, welche zu sammeln?","\n","ev","str","^Klar, ich helfe.","/str","/ev",{"*":".^.c-0","flg":20},"ev","str","^Vielleicht später.","/str","/ev",{"*":".^.c-1","flg":20},{"->":"0.4"},{"c-0":["\n","ev",true,"/ev",{"VAR=":"quest_mushroom_active","re":true},"#","^color whisper","/#","^Danke! Ich warte hier.","\n",{"->":"ende"},{"#f":5}],"c-1":["\n","#","^color whisper","/#","^Schade. Komm gerne nochmal vorbei.","\n",{"->":"ende"},{"#f":5}]}]}],"nop","\n",["done",{"#n":"g-0"}],null],"done",{"ende":[["ev","str","^Bis dann","/str","/ev",{"*":".^.c-0","flg":20},{"c-0":["\n","#","^person_farewell","/#","end",{"#f":5}]}],null],"global decl":["ev",0,{"VAR=":"mushrooms_collected"},false,{"VAR=":"quest_mushroom_active"},false,{"VAR=":"quest_mushroom_done"},"/ev","end",null]}],"listDefs":{}}
|
||||
@@ -0,0 +1,83 @@
|
||||
# color suzanne
|
||||
Hallo! Ich bin Suzanne, der Affe von Blender.
|
||||
Möchtest du wissen, wie diese Engine funktioniert?
|
||||
* [Klar, leg los]
|
||||
-> menu
|
||||
* [Nein, danke.]
|
||||
-> ende
|
||||
|
||||
=== menu ===
|
||||
# color suzanne
|
||||
Worüber soll ich plaudern?
|
||||
* [Ink by Inkle] -> story
|
||||
* [Renderer & Pixel] -> render
|
||||
* [Welt & Physik] -> welt
|
||||
* [Interaktion & Picking] -> interakt
|
||||
* [Audio] -> audio
|
||||
* [Warum so wenig Pixel?] -> pixel
|
||||
* [Nichts weiter, danke] -> ende
|
||||
|
||||
=== render ===
|
||||
# color suzanne
|
||||
Alles, was du siehst, wird als ein Pixelbuffer von der CPU gerendert. Der Pixelbuffer ist einfach eine Liste von Bytes, die einen Frame beschreibt. Jedes Pixel hat dabei genau einen Byte, also 8 Bit speicherplatz. Deshalb haben wir auch nur 256 unterschiedliche Farbwerte pro Frame.
|
||||
|
||||
Texturen sind 8-bit indexed TGAs, die durch eine 256-Farben-Palette laufen. Das ist ein simples Dateiformat, das viele Grafikprogramme unterstützen und das sehr einfach zu verarbeiten ist.
|
||||
Farb-Paletten sind auch TGA-Dateien, mit 16x16 Pixeln im normalen 24 Bit RGB-Raum. Diese werden von der Engine gelesen und als Farbpalette festgelegt.
|
||||
|
||||
Schalte Mal den Wireframe-Modus ist im ESC-Menü ein!
|
||||
|
||||
* [Zurück zum Menü] -> menu
|
||||
|
||||
=== welt ===
|
||||
# color suzanne
|
||||
Geometrie kommt aus OBJ-Dateien, die z.B. in Blender bearbeitet werden. Dabei gibt es Konventionen:
|
||||
|
||||
Mesh-Namen mit Prefix steuern die Rolle: VIS_ ist nur sichtbar, COL_ ist nur
|
||||
Kollision, INT_ ist nur Trigger (weder sichtbar, noch kollision). Alle anderen Meshes sind sichtbar und kollidieren.
|
||||
|
||||
Materialien werden in assets/materials/materials.toml definiert. Dort liegen auch die Texturen.
|
||||
|
||||
* [Zurück zum Menü] -> menu
|
||||
|
||||
=== interakt ===
|
||||
# color suzanne
|
||||
|
||||
Diese Engine nutzt ein Signal-System mit Triggern und Actions.
|
||||
|
||||
In signals.toml werden Trigger definiert und Actions zugewiesen. Schau dir gern die Datei Mal an.
|
||||
|
||||
Jedes Objekt kann einen eigenen Trigger bei Spieler-Interaktion haben. Dieser Trigger heißt einfach wie das Objekt. VIS_mushroom hat also den Trigger [VIS_mushroom], der Actions auslöst.
|
||||
|
||||
* [Zurück zum Menü] -> menu
|
||||
|
||||
=== story ===
|
||||
# color suzanne
|
||||
Dialoge und Interaktionen werden in Inkscript geschrieben. Die Engine liest kompilierte .ink.json-Dateien.
|
||||
|
||||
Im backend läuft bladeink als Interpreter. Dadurch ist der komplette Inky-Standard unterstützt. Color-Tags ändern die Textfarbe. Alle anderen Tags werden als Signals in der Engine dispatched.
|
||||
|
||||
Die Engine hat einen Variablen-Speicher, der mit dem Variablenspeicher vor und nach jedem Dialog synchronisiert wird. Variablen im Inkscript sind also global verfügbar und können durch Signals gesteuert werden.
|
||||
|
||||
* [Zurück zum Menü] -> menu
|
||||
|
||||
=== audio ===
|
||||
# color suzanne
|
||||
Die Sound-Engine ist noch sehr rudimentär. WAV-Dateien werden durch die Engine geladen. Die Sounds können durch Signals gesteuert werden. Es gibt noch keine 3D-Effekte.
|
||||
|
||||
* [Zurück zum Menü] -> menu
|
||||
|
||||
=== pixel ===
|
||||
# color suzanne
|
||||
Diese Engine unterstützt beliebige Auflösung. Aktuell sind es 320x240px also qVGA. Ich find das schick!
|
||||
Aber 640x480px haben auch noch einen guten Retro-Look. Bei 1920p brauchen wir dann schon bessere Assets, sonst wirds hässlich.
|
||||
|
||||
Starte die Engine im Terminal mit ./irl3d --resolution VGA
|
||||
|
||||
* [Zurück zum Menü] -> menu
|
||||
|
||||
=== ende ===
|
||||
# color suzanne
|
||||
Schön, dass du dich hier umschaust. Viel Spaß noch!
|
||||
* [Bis dann]
|
||||
# suzanne_farewell
|
||||
-> END
|
||||
@@ -0,0 +1,2 @@
|
||||
# Blender 5.0.0 MTL File: 'None'
|
||||
# www.blender.org
|
||||
@@ -0,0 +1,48 @@
|
||||
// Game: Generic
|
||||
// Format: Standard
|
||||
// entity 0
|
||||
{
|
||||
"classname" "worldspawn"
|
||||
"wad" ""
|
||||
// brush 0
|
||||
{
|
||||
( -80 -16 -32 ) ( -80 -15 -32 ) ( -80 -16 -31 ) 256_arcade_carpet 96 -16 0 0.2 0.2
|
||||
( -112 -16 -32 ) ( -112 -16 -31 ) ( -111 -16 -32 ) 256_arcade_carpet 80 -16 0 0.2 0.2
|
||||
( -112 -16 -32 ) ( -111 -16 -32 ) ( -112 -15 -32 ) 256_arcade_carpet 80 -96 0 0.2 0.2
|
||||
( 48 96 -16 ) ( 48 97 -16 ) ( 49 96 -16 ) 256_arcade_carpet 5.3333435 -57.379303 0 0.2 0.2
|
||||
( 48 112 -16 ) ( 49 112 -16 ) ( 48 112 -15 ) 256_arcade_carpet 80 -16 0 0.2 0.2
|
||||
( 80 96 -16 ) ( 80 96 -15 ) ( 80 97 -16 ) 256_arcade_carpet 96 -16 0 0.2 0.2
|
||||
}
|
||||
// brush 1
|
||||
{
|
||||
( -48 0 4 ) ( -48 1 4 ) ( -48 0 5 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( 48 -16 -16 ) ( 47 -16 -16 ) ( 48 -16 -15 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( 48 -16 -16 ) ( 48 -15 -16 ) ( 47 -16 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( -48 0 80 ) ( -49 0 80 ) ( -48 1 80 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( -48 0 4 ) ( -48 0 5 ) ( -49 0 4 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( 48 -16 -16 ) ( 48 -16 -15 ) ( 48 -15 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
}
|
||||
// brush 2
|
||||
{
|
||||
( -91.71281292110203 32 4 ) ( -91.21281292110203 32.86602540378445 4 ) ( -91.71281292110203 32 5 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( -30.430780618346965 -21.856406460551035 -16 ) ( -31.296806022131403 -21.356406460551035 -16 ) ( -30.430780618346965 -21.856406460551035 -15 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( -30.430780618346965 -21.856406460551035 -16 ) ( -29.930780618346965 -20.990381056766598 -16 ) ( -31.296806022131403 -21.356406460551035 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( -105.56921938165303 40 80 ) ( -106.43524478543748 40.500000000000014 80 ) ( -105.06921938165303 40.86602540378446 80 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( -105.56921938165303 40 4 ) ( -105.56921938165303 40 5 ) ( -106.43524478543748 40.500000000000014 4 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( -44.28718707889798 -13.856406460551028 -16 ) ( -44.28718707889798 -13.856406460551028 -15 ) ( -43.78718707889798 -12.99038105676659 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
}
|
||||
// brush 3
|
||||
{
|
||||
( 36.28718707889797 0 4 ) ( 35.787187078897965 0.866025403784441 4 ) ( 36.28718707889797 0 5 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( 22.430780618346965 -8 4 ) ( 22.430780618346965 -8 5 ) ( 21.56475521456251 -8.499999999999979 4 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( 113.56921938165306 26.14359353944895 -16 ) ( 113.06921938165306 27.0096189432334 -16 ) ( 112.70319397786861 25.643593539448965 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( 22.430780618346965 -8 80 ) ( 21.56475521456251 -8.499999999999979 80 ) ( 21.930780618346944 -7.1339745962155305 80 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( 113.56921938165306 26.14359353944895 -16 ) ( 112.70319397786861 25.643593539448965 -16 ) ( 113.56921938165306 26.14359353944895 -15 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
( 99.71281292110204 18.143593539448965 -16 ) ( 99.71281292110204 18.143593539448965 -15 ) ( 99.21281292110204 19.009618943233413 -16 ) 256_cement_dirty_rough_wall 0 0 0 1 1
|
||||
}
|
||||
}
|
||||
// entity 1
|
||||
{
|
||||
"classname" "info_player_start"
|
||||
"origin" "2 52 8"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// Game: Generic
|
||||
// Format: Standard
|
||||
// entity 0
|
||||
{
|
||||
"classname" "worldspawn"
|
||||
"wad" ""
|
||||
// brush 0
|
||||
{
|
||||
( -144 -64 -112 ) ( -144 -63 -112 ) ( -144 -64 -111 ) placeholder 0 -64 0 0.2 0.2
|
||||
( -144 -304 -112 ) ( -144 -304 -111 ) ( -143 -304 -112 ) placeholder 224 -64 0 0.2 0.2
|
||||
( -144 -64 -112 ) ( -143 -64 -112 ) ( -144 -63 -112 ) placeholder 224 0 0 0.2 0.2
|
||||
( -16 64 -80 ) ( -16 65 -80 ) ( -15 64 -80 ) placeholder 224 0 0 0.2 0.2
|
||||
( -16 192 -80 ) ( -15 192 -80 ) ( -16 192 -79 ) placeholder 224 -64 0 0.2 0.2
|
||||
( 304 64 -80 ) ( 304 64 -79 ) ( 304 65 -80 ) placeholder 0 -64 0 0.2 0.2
|
||||
}
|
||||
// brush 1
|
||||
{
|
||||
( 0 -128 -16 ) ( 0 -127 -16 ) ( 0 -128 -15 ) placeholder 0 0 0 1 1
|
||||
( 0 -128 -16 ) ( 0 -128 -15 ) ( 1 -128 -16 ) placeholder -32 0 0 1 1
|
||||
( 0 -128 -16 ) ( 1 -128 -16 ) ( 0 -127 -16 ) placeholder -32 0 0 1 1
|
||||
( 64 -96 0 ) ( 64 -95 0 ) ( 65 -96 0 ) placeholder -32 0 0 1 1
|
||||
( 64 -96 0 ) ( 65 -96 0 ) ( 64 -96 1 ) placeholder -32 0 0 1 1
|
||||
( 64 -96 0 ) ( 64 -96 1 ) ( 64 -95 0 ) placeholder 0 0 0 1 1
|
||||
}
|
||||
// brush 2
|
||||
{
|
||||
( 64 -144 -32 ) ( 64 -143 -32 ) ( 64 -144 -31 ) placeholder -16 -16 0 1 1
|
||||
( 64 -144 -32 ) ( 64 -144 -31 ) ( 65 -144 -32 ) placeholder -32 -16 0 1 1
|
||||
( 64 -144 -32 ) ( 65 -144 -32 ) ( 64 -143 -32 ) placeholder -32 16 0 1 1
|
||||
( 192 -80 -16 ) ( 192 -79 -16 ) ( 193 -80 -16 ) placeholder -32 16 0 1 1
|
||||
( 192 -80 -16 ) ( 193 -80 -16 ) ( 192 -80 -15 ) placeholder -32 -16 0 1 1
|
||||
( 192 -80 -16 ) ( 192 -80 -15 ) ( 192 -79 -16 ) placeholder -16 -16 0 1 1
|
||||
}
|
||||
// brush 3
|
||||
{
|
||||
( 80 16 32 ) ( 80 15 32 ) ( 80 16 31 ) placeholder 0 -16 180 1 1
|
||||
( 144 -96 16 ) ( 145 -96 16 ) ( 144 -96 15 ) placeholder 16 -16 0 1 -1
|
||||
( 144 -96 16 ) ( 144 -97 16 ) ( 145 -96 16 ) placeholder 16 0 0 1 -1
|
||||
( 80 16 32 ) ( 81 16 32 ) ( 80 15 32 ) placeholder 16 0 0 1 -1
|
||||
( 80 16 32 ) ( 80 16 31 ) ( 81 16 32 ) placeholder 16 -16 0 1 -1
|
||||
( 144 -96 16 ) ( 144 -96 15 ) ( 144 -97 16 ) placeholder 0 -16 180 1 1
|
||||
}
|
||||
// brush 4
|
||||
{
|
||||
( 224 -48 -80 ) ( 224 -47 -80 ) ( 224 -48 -79 ) placeholder -144 -96 0 1 1
|
||||
( 224 -48 -80 ) ( 224 -48 -79 ) ( 225 -48 -80 ) placeholder -32 -96 0 1 1
|
||||
( 224 -48 -80 ) ( 225 -48 -80 ) ( 224 -47 -80 ) placeholder -32 144 0 1 1
|
||||
( 288 16 -64 ) ( 288 17 -64 ) ( 289 16 -64 ) placeholder -32 144 0 1 1
|
||||
( 288 16 -64 ) ( 289 16 -64 ) ( 288 16 -63 ) placeholder -32 -96 0 1 1
|
||||
( 288 16 -64 ) ( 288 16 -63 ) ( 288 17 -64 ) placeholder -144 -96 0 1 1
|
||||
}
|
||||
// brush 5
|
||||
{
|
||||
( -144 -560 -80 ) ( -144 -559 -80 ) ( -144 -560 -79 ) placeholder -80 96 0 0.2 0.2
|
||||
( -144 -320 -80 ) ( -144 -320 -79 ) ( -143 -320 -80 ) placeholder -32 96 0 0.2 0.2
|
||||
( -144 -560 -80 ) ( -143 -560 -80 ) ( -144 -559 -80 ) placeholder -32 80 0 0.2 0.2
|
||||
( -16 -432 16 ) ( -16 -431 16 ) ( -15 -432 16 ) placeholder -32 80 0 0.2 0.2
|
||||
( -16 -304 -48 ) ( -15 -304 -48 ) ( -16 -304 -47 ) placeholder -32 96 0 0.2 0.2
|
||||
( 304 -432 -48 ) ( 304 -432 -47 ) ( 304 -431 -48 ) placeholder -80 96 0 0.2 0.2
|
||||
}
|
||||
// brush 6
|
||||
{
|
||||
( -160 -240 -16 ) ( -160 -241 -16 ) ( -160 -240 -17 ) placeholder -240 96 180 1 1
|
||||
( -64 -304 -32 ) ( -63 -304 -32 ) ( -64 -304 -33 ) placeholder 208 96 0 1 -1
|
||||
( -64 -352 -80 ) ( -64 -353 -80 ) ( -63 -352 -80 ) placeholder 208 240 0 1 -1
|
||||
( -160 -240 16 ) ( -159 -240 16 ) ( -160 -241 16 ) placeholder 208 240 0 1 -1
|
||||
( -160 192 -16 ) ( -160 192 -17 ) ( -159 192 -16 ) placeholder 208 96 0 1 -1
|
||||
( -144 -352 -32 ) ( -144 -352 -33 ) ( -144 -353 -32 ) placeholder -240 96 180 1 1
|
||||
}
|
||||
// brush 7
|
||||
{
|
||||
( 176 -144 -80 ) ( 176 -143 -80 ) ( 176 -144 -79 ) placeholder 176 0 0 1 1
|
||||
( 176 -144 -80 ) ( 176 -144 -79 ) ( 177 -144 -80 ) placeholder 16 0 0 1 1
|
||||
( 176 -144 -80 ) ( 177 -144 -80 ) ( 176 -143 -80 ) placeholder 16 -176 0 1 1
|
||||
( 240 -80 -48 ) ( 240 -79 -48 ) ( 241 -80 -48 ) placeholder 16 -176 0 1 1
|
||||
( 240 -80 -64 ) ( 241 -80 -64 ) ( 240 -80 -63 ) placeholder 16 0 0 1 1
|
||||
( 240 -80 -64 ) ( 240 -80 -63 ) ( 240 -79 -64 ) placeholder 176 0 0 1 1
|
||||
}
|
||||
}
|
||||
// entity 1
|
||||
{
|
||||
"classname" "info_player_start"
|
||||
"origin" "112 -48 56"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
# Signal → Action Mapping. Trigger feuern Signals (Strings); hier wird
|
||||
# definiert, was bei jedem Signal passiert. Signal-Name = Trigger-Quelle:
|
||||
# für Objekt-Interaktion ist es der Instance-Name ohne Blender-Suffix.
|
||||
#
|
||||
# Actions sind Strings im Format "verb args". Verben:
|
||||
# start_ink <script> Story laden (Frontend treibt sie)
|
||||
# debug_log <text> eprintln!
|
||||
# set <name> <value> KV setzen (true/false/i32/f32/string)
|
||||
# inc <name> [<delta>] KV-Integer +Delta (Default +1)
|
||||
# clear <name> KV-Eintrag entfernen
|
||||
# hide_object <name> deferred (Frontend): Instance ausblenden
|
||||
# play_sound <file> deferred (Frontend): SFX abspielen
|
||||
#
|
||||
# Reserviertes Signal `[init]` feuert einmal vor der Game-Loop —
|
||||
# idealer Ort für KV-Defaults.
|
||||
|
||||
[init]
|
||||
actions = [
|
||||
"set cube_clicks 0",
|
||||
"set mushrooms_collected 0",
|
||||
"set quest_mushroom_active false",
|
||||
"set quest_mushroom_done false",
|
||||
]
|
||||
|
||||
[Mushroom]
|
||||
actions = [
|
||||
"inc mushrooms_collected",
|
||||
"play_sound pickup.wav",
|
||||
"hide_object $self",
|
||||
]
|
||||
|
||||
[Person]
|
||||
actions = [
|
||||
"play_sound greet.wav",
|
||||
"start_ink person.ink.json",
|
||||
]
|
||||
|
||||
[Suzanne]
|
||||
actions = [
|
||||
"start_ink suzanne.ink.json",
|
||||
]
|
||||
|
||||
[Cube]
|
||||
actions = [
|
||||
"inc cube_clicks",
|
||||
"start_ink cube.ink.json",
|
||||
]
|
||||
|
||||
[person_farewell]
|
||||
actions = [
|
||||
"debug_log Person hat sich verabschiedet",
|
||||
]
|
||||
|
||||
[tiffany]
|
||||
actions = [
|
||||
"start_ink cinema.ink.json",
|
||||
]
|
||||
|
After Width: | Height: | Size: 192 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 256 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,59 @@
|
||||
//! Build-Hook: kompiliert geänderte Ink-Dialoge automatisch beim Cargo-Build.
|
||||
//!
|
||||
//! Reines „Wann"-Stück — das eigentliche Bauen macht `tools/build-dialogues.sh`
|
||||
//! (und bleibt von Hand aufrufbar). Hier melden wir Cargo nur, wann es uns
|
||||
//! erneut ausführen soll, und stoßen das Skript an.
|
||||
//!
|
||||
//! Cargo führt build.rs ausschließlich dann erneut aus, wenn eine der unten
|
||||
//! via `rerun-if-changed` gemeldeten Dateien sich ändert. Unveränderte
|
||||
//! Rebuilds kosten also nichts; nur nach einer `.ink`-Änderung läuft
|
||||
//! inklecate (inkrementell nur für die betroffene Datei).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
let root = env!("CARGO_MANIFEST_DIR");
|
||||
let script = Path::new(root).join("tools/build-dialogues.sh");
|
||||
let ink_dir = Path::new(root).join("assets/interactions");
|
||||
|
||||
// Wann soll Cargo build.rs erneut ausführen?
|
||||
println!("cargo:rerun-if-changed=tools/build-dialogues.sh");
|
||||
println!("cargo:rerun-if-changed=tools/inklecate");
|
||||
// Verzeichnis-mtime fängt neu hinzugekommene/gelöschte .ink-Dateien.
|
||||
println!("cargo:rerun-if-changed=assets/interactions");
|
||||
// Inhaltsänderungen einzelner .ink-Dateien einzeln melden.
|
||||
if let Ok(entries) = std::fs::read_dir(&ink_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.extension().and_then(|s| s.to_str()) == Some("ink") {
|
||||
println!("cargo:rerun-if-changed={}", p.display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skript ausführen. Ausgabe einsammeln, damit ein Dialog-Fehler sichtbar
|
||||
// wird (Cargo blendet Build-Script-stdout sonst aus).
|
||||
match Command::new("bash").arg(&script).output() {
|
||||
Ok(out) if out.status.success() => { /* still bei Erfolg */ }
|
||||
Ok(out) => {
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
for line in stdout.lines().chain(stderr.lines()) {
|
||||
println!("cargo:warning={line}");
|
||||
}
|
||||
panic!(
|
||||
"Dialog-Build fehlgeschlagen — siehe Warnungen oben. \
|
||||
Einzeln debuggen: ./tools/build-dialogues.sh"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
// bash/Skript nicht startbar (z.B. fremdes OS): den Build nicht
|
||||
// blockieren, nur warnen — die committeten .ink.json reichen.
|
||||
println!(
|
||||
"cargo:warning=Dialog-Build übersprungen ({e}). \
|
||||
Bei Bedarf von Hand: ./tools/build-dialogues.sh"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
# wds — Renderer-Plan
|
||||
|
||||
Stand: 2026-06-12. Entscheidungen aus der irl3d-Analyse, festgehalten bevor
|
||||
der Projekt-Einstieg über die Kern-Systeme (Signals/KV, CLI-Phase) läuft.
|
||||
|
||||
## Ziel
|
||||
|
||||
PS1-artiger Look, von vornherein nativ und simpel implementiert statt
|
||||
emuliert: affines Texture-Mapping, Integer-Vertex-Koordinaten, 16-Bit-Farbe.
|
||||
Niedrige interne Auflösung (320×240), hochskaliert aufs Fenster.
|
||||
|
||||
Referenz ist `../irl3d`: gleiche Engine-Idee (datengetrieben, eine
|
||||
OBJ-Szene, Signals/KV/Ink), aber dort als 8-Bit-Palette-CPU-Softwarerenderer
|
||||
in Q16.16. wds ersetzt den Grafikpfad durch echtes GPU-Rastern.
|
||||
|
||||
## Hardware-Target
|
||||
|
||||
Mittelklasse-Laptop von 2015 (Klasse: i5-5200U + Intel HD 5500).
|
||||
Konsequenzen:
|
||||
|
||||
- Zwei schwache CPU-Kerne → Transformation gehört auf die GPU, die CPU
|
||||
bleibt für Physik und Spiellogik frei.
|
||||
- Per-Frame-Vertex-Streaming (CPU-Transform, GTE-Stil) wäre auf alten
|
||||
iGPU-Treibern der riskante Pfad (Buffer-Upload-Stalls) → statische
|
||||
Vertex-Buffer, einmal hochgeladen.
|
||||
- wgpu-Backend: Vulkan primär (ANV unterstützt Gen8+), GL als Fallback
|
||||
einplanen. Keine Features jenseits von Baseline-WebGPU benutzen.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
1. **wgpu direkt, kein pbio.** pbio ist eine Abstraktion für
|
||||
Software-Renderer (8-Bit-Framebuffer + Palette-Blit) — für echtes
|
||||
GPU-Rastern Verschwendung. winit + wgpu direkt im Projekt.
|
||||
|
||||
2. **Vertex-Shader-Transform mit Pixel-Snapping.** Model→View→Projektion im
|
||||
Vertex-Shader. Danach `xy/w` auf das Pixelraster der internen Auflösung
|
||||
runden und wieder mit `w` multiplizieren → Vertex-Jitter wie auf der PS1,
|
||||
authentisch gerechnet, nicht nachgestellt.
|
||||
|
||||
3. **Affines Texture-Mapping via `@interpolate(linear)`.** WGSL kann die
|
||||
perspektivische Korrektur pro Varying abschalten. UVs (und ggf.
|
||||
Vertex-Colors) linear interpolieren → das Textur-Wobbeln kommt gratis.
|
||||
Gegenmittel bleibt wie auf der PS1 das Unterteilen großer Flächen im
|
||||
Asset, nicht Shader-Tricks.
|
||||
|
||||
4. **RGB555 + Ordered Dithering im Fragment-Shader.** wgpu hat kein
|
||||
555-Render-Target-Format; Render-Target bleibt RGBA8 bei 320×240, aber
|
||||
der Fragment-Shader quantisiert auf 5 Bit pro Kanal mit 4×4-Bayer-Dither.
|
||||
Die Farbreduktion ist echt, nur das Speicherformat ist modern.
|
||||
Das 16. Bit entspricht dem PS1-Maskbit → bei uns **1-Bit-Alpha**
|
||||
(Alpha-Test, kein Blending) — Fortführung der Index-0-Transparenz
|
||||
aus irl3d.
|
||||
|
||||
5. **Z-Buffer: ja.** Ordering-Tables wären nur eine technische Hürde, kein
|
||||
ästhetischer Gewinn. Standard-Depth-Buffer auf dem internen Target.
|
||||
|
||||
6. **UVs aus dem OBJ (`vt`).** irl3d ignoriert `vt` und mappt planar über
|
||||
Welt-Koordinaten; wds liest echte UVs. PS1-Spiele hatten handgemachte
|
||||
UVs — das gehört zum Look.
|
||||
|
||||
7. **Licht: gebacken zuerst.** Nur Gouraud-Shading wäre zu einseitig.
|
||||
Plan: Lichteffekte in die Szene backen (Blender → Vertex-Colors),
|
||||
im Shader mit der Textur multiplizieren und mitquantisieren. Dynamische
|
||||
per-Vertex-Punktlichter (im Vertex-Shader, additiv auf die gebackene
|
||||
Farbe) als spätere Stufe. Kein Per-Pixel-Lighting.
|
||||
|
||||
## Pipeline-Skizze
|
||||
|
||||
```
|
||||
statische VBOs (Pos i16/f32, UV, Vertex-Color)
|
||||
→ Vertex-Shader: Model·View·Proj, dann Snap auf 320×240-Raster
|
||||
→ Raster (Hardware), UV/Color @interpolate(linear)
|
||||
→ Fragment: Texel-Fetch (nearest), × Vertex-Color,
|
||||
Alpha-Test (1 Bit), RGB555-Quantisierung + 4×4-Bayer
|
||||
→ RGBA8-Offscreen 320×240 + Depth
|
||||
→ Blit-Pass: Nearest-Upscale mit Letterbox (4:3) aufs Fenster
|
||||
```
|
||||
|
||||
Draw-Granularität wie irl3d: pro Instance ein Draw (Uniform: Pos + Yaw),
|
||||
Material-Batching später, wenn es weh tut.
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- **Asset-Pipeline:** Texture-Painting in Blender ist mühsam. Kandidaten
|
||||
für Textur-pro-Fläche-Workflows: TrenchBroom (Map-Format-Import nötig),
|
||||
Crocotile 3D, Blockbench. Entscheidung wenn der Szenen-Loader ansteht.
|
||||
- **Texturformat:** direkte True-Color-Texturen statt 8-Bit-CLUT-TGA —
|
||||
wir arbeiten durchgehend mit 15-Bit-True-Color, es gibt keine globale
|
||||
Palette mehr (das `load_palette`-Verb ist entfernt). Offen bleibt nur
|
||||
das Dateiformat (TGA/PNG) und ob Quantisierung beim Import oder erst
|
||||
im Shader passiert.
|
||||
- **Fog / Distance-Fade:** irl3d hat hartes Pop-In bei `CULL_DIST` —
|
||||
in wds von Anfang an als Fade Richtung Sky-Farbe einplanen (im Shader
|
||||
trivial, passt zur PS1).
|
||||
- **Skybox:** Cubemap wie irl3d oder schlicht Far-Plane-Farbe + Fog.
|
||||
- Interne Auflösung umschaltbar lassen (QVGA/VGA wie irl3d).
|
||||
|
||||
## Aus irl3d portierbar (renderer-unabhängig)
|
||||
|
||||
signals, kv, ink, story_ctrl, game (ActionCtx-Idee), physics, pick,
|
||||
camera, obj-Loader (um `vt` erweitern), tga, wav, audio, assets,
|
||||
fix/math (Q16.16 bleibt für Spiellogik/Physik sinnvoll; der Renderer
|
||||
selbst rechnet f32 — die GPU kennt ohnehin nur Floats).
|
||||
|
||||
## Projektphasen
|
||||
|
||||
1. **CLI-Phase (läuft):** KV-Store, Signal/Action-Dispatcher,
|
||||
Ink-Integration, Konsolen-REPL als erstes Frontend. Deferred Actions
|
||||
als Daten (`hide_object`, `play_sound`), damit spätere Subsysteme sie
|
||||
konsumieren, ohne dass der Kern sie kennt.
|
||||
2. Datenmodell (Mesh/Instance/Material/Scene + TOML-Formate) headless.
|
||||
3. Renderer nach diesem Plan, gegen das stehende Datenmodell.
|
||||
4. Physik, Audio, Panel-UI andocken (Ports aus irl3d).
|
||||
@@ -0,0 +1,18 @@
|
||||
# Assets
|
||||
|
||||
## Texturen
|
||||
https://www.manytextures.com/
|
||||
https://opengameart.org/content/arcade-carpet-textures
|
||||
|
||||
|
||||
# Tutorials
|
||||
|
||||
## Blender
|
||||
|
||||
Blender UI Base Setup + Tips
|
||||
https://www.youtube.com/watch?v=sZeGW0otiJI
|
||||
|
||||
Rigging in PS1 Look:
|
||||
https://www.youtube.com/watch?v=puwu9hZmQYI
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Konsolen-REPL — Headless-Treiber für Tests und Betrieb ohne GPU.
|
||||
//!
|
||||
//! Liest Zeilen von stdin, gibt sie an `Session::exec` und druckt die
|
||||
//! Ausgabe. Befehls- und Dialoglogik liegen in `session`, damit das
|
||||
//! Fenster-Frontend dieselbe Session ohne Duplikat steuern kann.
|
||||
|
||||
use std::io::{self, Write};
|
||||
|
||||
use crate::session::{Mode, Session};
|
||||
|
||||
pub fn run(mut session: Session, signals_path: &str) {
|
||||
println!("wds-Konsole — {} Signale aus {}. `help` für Befehle.",
|
||||
session.game.signals.len(), signals_path);
|
||||
|
||||
// Reserviertes `[init]`-Signal: KV-Defaults setzen, bevor Stories laufen.
|
||||
for line in session.start() { println!("{line}"); }
|
||||
|
||||
loop {
|
||||
let Some(input) = read_line(prompt_for(&session.mode)) else { break };
|
||||
let r = session.exec(input.trim());
|
||||
for line in r.output { println!("{line}"); }
|
||||
if r.quit { break; }
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt je nach Zustand: Befehl, Dialog-Weiterblättern oder Choice-Wahl.
|
||||
fn prompt_for(mode: &Mode) -> &'static str {
|
||||
match mode {
|
||||
Mode::Dialog(d) if !d.choices.is_empty() => " wahl> ",
|
||||
Mode::Dialog(_) => " [Enter] ",
|
||||
Mode::Menu => "menü> ",
|
||||
Mode::FirstPerson => "spiel> ",
|
||||
Mode::Free => "> ",
|
||||
}
|
||||
}
|
||||
|
||||
/// Zeile von stdin lesen; `None` bei EOF (Ctrl-D) oder Lesefehler.
|
||||
fn read_line(prompt: &str) -> Option<String> {
|
||||
print!("{prompt}");
|
||||
io::stdout().flush().ok();
|
||||
let mut buf = String::new();
|
||||
match io::stdin().read_line(&mut buf) {
|
||||
Ok(0) | Err(_) => None,
|
||||
Ok(_) => Some(buf.trim_end().to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! Asset-Pfadauflösung: Distribution-Layout (assets/ neben der Binary)
|
||||
//! oder Dev-Layout (assets/ im CWD, also Projektroot bei `cargo run`).
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static BASE: OnceLock<PathBuf> = OnceLock::new();
|
||||
|
||||
fn base() -> &'static PathBuf {
|
||||
BASE.get_or_init(|| {
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
if dir.join("assets").exists() {
|
||||
return dir.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
|
||||
})
|
||||
}
|
||||
|
||||
/// Wandelt einen relativen Pfad wie `"assets/signals.toml"` in einen
|
||||
/// absoluten Pfad relativ zum Asset-Basisverzeichnis um.
|
||||
pub fn path(rel: &str) -> String {
|
||||
base().join(rel).to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
//! Brush-Collision: Swept-AABB-Trace gegen konvexe Brushes (Quake-Hull-Idee).
|
||||
//!
|
||||
//! Ein Brush ist der Schnitt seiner Halbräume `{ n·x ≤ d }` (n nach außen) —
|
||||
//! genau die Ebenen, die auch render::brush rekonstruiert, hier aber in
|
||||
//! **Engine-Koords** und **vollständig** (keine Koplanar-Elimination, kein
|
||||
//! Culling: für Collision zählt das ganze solide Volumen).
|
||||
//!
|
||||
//! Die Box wird nicht selbst getract, sondern per Minkowski-Aufblasung in den
|
||||
//! Ebenen versenkt: jede Ebene rückt um die auf ihre Normale projizierte
|
||||
//! Box-Halbgröße nach außen (`d' = d + |n|·half`). Damit wird Box-vs-Brush zur
|
||||
//! Punkt-vs-aufgeblasener-Brush-Frage, und die Trace ist ein simpler
|
||||
//! Halbraum-Clip des Segments (Eintritts-/Austritts-Bruch). Ein `SKIN` hält
|
||||
//! den Mittelpunkt eine Haaresbreite vor der Fläche, damit der Folgeframe nicht
|
||||
//! sofort wieder im Kontakt steckt.
|
||||
//!
|
||||
//! Headless wie der Rest von `engine`: hängt nur an `map` (für die Brush-Ebenen
|
||||
//! und die geteilte Koordinaten-Umrechnung). Der Player ruft `trace` in
|
||||
//! `player::step`; gebaut wird die Welt einmal vom Renderer aus der `Map`.
|
||||
|
||||
use crate::engine::map::{self, Map};
|
||||
|
||||
/// Mindestabstand (units), den der Box-Mittelpunkt vor einer Fläche hält —
|
||||
/// verhindert Re-Kollision/Jitter im Folgeframe. ~1 cm, unsichtbar.
|
||||
const SKIN: f32 = 0.01;
|
||||
|
||||
/// Eine nach außen orientierte Ebene `n·x ≤ d` (innen = Halbraum).
|
||||
pub(crate) struct Plane {
|
||||
pub(crate) n: [f32; 3],
|
||||
pub(crate) d: f32,
|
||||
}
|
||||
|
||||
/// Ein konvexer Brush = Schnitt seiner Halbräume.
|
||||
struct ConvexBrush {
|
||||
planes: Vec<Plane>,
|
||||
}
|
||||
|
||||
/// Die statische Kollisionswelt: alle soliden Brushes der Map.
|
||||
pub struct CollisionWorld {
|
||||
brushes: Vec<ConvexBrush>,
|
||||
}
|
||||
|
||||
/// Ergebnis einer Trace: Bruchteil entlang des Segments bis zum Kontakt und
|
||||
/// die nach außen zeigende Trefferebenen-Normale (zum Gleiten/Boden-Erkennen).
|
||||
pub struct Hit {
|
||||
pub frac: f32,
|
||||
pub normal: [f32; 3],
|
||||
}
|
||||
|
||||
impl CollisionWorld {
|
||||
/// Kollisionswelt aus allen Brushes der Map bauen. Jede Brush-Face liefert
|
||||
/// eine Ebene (aus drei Punkten, nach Engine-Koords gedreht).
|
||||
pub fn build(world: &Map) -> Self {
|
||||
let mut brushes = Vec::new();
|
||||
for ent in &world.entities {
|
||||
for b in &ent.brushes {
|
||||
if b.faces.len() < 4 { continue; } // kein geschlossenes Volumen
|
||||
let planes = b.faces.iter().map(|f| plane_from(&f.plane)).collect();
|
||||
brushes.push(ConvexBrush { planes });
|
||||
}
|
||||
}
|
||||
Self { brushes }
|
||||
}
|
||||
|
||||
/// Leere Welt (keine Brushes) — Default, bis eine Map geladen ist.
|
||||
pub fn empty() -> Self {
|
||||
Self { brushes: Vec::new() }
|
||||
}
|
||||
|
||||
/// Eine AABB (Halbmaße `half`) von `start` nach `end` (Box-Mittelpunkte)
|
||||
/// sweepen. Liefert den frühesten Kontakt über alle Brushes, sonst `None`.
|
||||
pub fn trace(&self, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
||||
let mut nearest: Option<Hit> = None;
|
||||
for b in &self.brushes {
|
||||
if let Some(h) = trace_brush(b, start, end, half) {
|
||||
if nearest.as_ref().map_or(true, |n| h.frac < n.frac) {
|
||||
nearest = Some(h);
|
||||
}
|
||||
}
|
||||
}
|
||||
nearest
|
||||
}
|
||||
}
|
||||
|
||||
/// Segment `start→end` gegen einen aufgeblasenen konvexen Brush clippen.
|
||||
/// `None`, wenn das Segment den Brush verfehlt oder der Start schon drin steckt
|
||||
/// (dann nicht blocken — sonst bliebe der Player hängen).
|
||||
fn trace_brush(b: &ConvexBrush, start: [f32; 3], end: [f32; 3], half: [f32; 3]) -> Option<Hit> {
|
||||
let mut enter = f32::NEG_INFINITY; // größter Eintritts-Bruch
|
||||
let mut leave = 1.0f32; // kleinster Austritts-Bruch
|
||||
let mut normal = [0.0f32; 3];
|
||||
let mut entered = false; // überhaupt eine Eintrittsebene gefunden?
|
||||
let mut started_outside = false;
|
||||
|
||||
for p in &b.planes {
|
||||
// Ebene um die Box-Halbgröße nach außen aufblasen (Minkowski).
|
||||
let d = p.d + p.n[0].abs() * half[0] + p.n[1].abs() * half[1] + p.n[2].abs() * half[2];
|
||||
let ds = dot(p.n, start) - d;
|
||||
let de = dot(p.n, end) - d;
|
||||
|
||||
if ds > 0.0 { started_outside = true; }
|
||||
if ds > 0.0 && de > 0.0 { return None; } // ganz außerhalb dieser Ebene
|
||||
if ds <= 0.0 && de <= 0.0 { continue; } // ganz innerhalb dieser Ebene
|
||||
|
||||
if ds > de {
|
||||
// Eintritt (außen → innen): SKIN-Rückzug, damit der Mittelpunkt
|
||||
// knapp vor der Fläche stoppt. `enter` darf dabei leicht negativ
|
||||
// werden (Kontakt liegt im SKIN-Band) — das wird unten auf 0
|
||||
// geklemmt, nicht verworfen, sonst rutschte ein ruhender Körper
|
||||
// im Folgeframe durch die Fläche.
|
||||
let f = (ds - SKIN) / (ds - de);
|
||||
if f > enter { enter = f; normal = p.n; entered = true; }
|
||||
} else {
|
||||
// Austritt (innen → außen).
|
||||
let f = ds / (ds - de);
|
||||
if f < leave { leave = f; }
|
||||
}
|
||||
}
|
||||
|
||||
// Treffer nur, wenn der Start außerhalb lag (sonst säße man fest), eine
|
||||
// Eintrittsebene existiert und das Eintrittsintervall vor dem Segmentende
|
||||
// beginnt.
|
||||
if started_outside && entered && enter < leave && enter < 1.0 {
|
||||
Some(Hit { frac: enter.max(0.0), normal })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Ebene aus drei Face-Punkten (Quake-Reihenfolge), gedreht nach Engine-Koords.
|
||||
/// Wie render::brush::plane, aber direkt im Engine-System: weil `to_engine` die
|
||||
/// Orientierung erhält (det +1), zeigt `cross(c−a, b−a)` weiter nach außen.
|
||||
fn plane_from(p: &[[f32; 3]; 3]) -> Plane {
|
||||
let a = map::to_engine(p[0]);
|
||||
let b = map::to_engine(p[1]);
|
||||
let c = map::to_engine(p[2]);
|
||||
let n = normalize(cross(sub(c, a), sub(b, a)));
|
||||
Plane { n, d: dot(n, a) }
|
||||
}
|
||||
|
||||
// --- kleine Vektor-Helfer (privat, wie render::brush; ein gemeinsames
|
||||
// engine::vec3 lohnt erst, falls ein dritter Nutzer auftaucht) ---------------
|
||||
|
||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
|
||||
|
||||
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
fn normalize(a: [f32; 3]) -> [f32; 3] {
|
||||
let len = dot(a, a).sqrt();
|
||||
if len > 0.0 { [a[0] / len, a[1] / len, a[2] / len] } else { a }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn aabb_brush(min: [f32; 3], max: [f32; 3]) -> Vec<Plane> {
|
||||
vec![
|
||||
Plane { n: [ 1.0, 0.0, 0.0], d: max[0] }, Plane { n: [-1.0, 0.0, 0.0], d: -min[0] },
|
||||
Plane { n: [0.0, 1.0, 0.0], d: max[1] }, Plane { n: [0.0, -1.0, 0.0], d: -min[1] },
|
||||
Plane { n: [0.0, 0.0, 1.0], d: max[2] }, Plane { n: [0.0, 0.0, -1.0], d: -min[2] },
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_brushes(brushes: Vec<Vec<Plane>>) -> CollisionWorld {
|
||||
CollisionWorld {
|
||||
brushes: brushes.into_iter().map(|planes| ConvexBrush { planes }).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Punktförmige Box (half=0) → reine Strahl-vs-Brush-Trace.
|
||||
const PT: [f32; 3] = [0.0, 0.0, 0.0];
|
||||
|
||||
#[test]
|
||||
fn trace_hits_near_face_with_normal() {
|
||||
let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]);
|
||||
// Von x=-1 nach x=3 (Gesamtweg 4), trifft die −X-Fläche bei x≈0.
|
||||
let h = w.trace([-1.0, 1.0, 1.0], [3.0, 1.0, 1.0], PT).unwrap();
|
||||
assert!((h.frac - 0.25).abs() < 0.02, "frac={}", h.frac);
|
||||
assert!(h.normal[0] < -0.5, "normal sollte -X sein: {:?}", h.normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_misses_returns_none() {
|
||||
let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]);
|
||||
// Läuft oberhalb der Box vorbei.
|
||||
assert!(w.trace([-1.0, 5.0, 1.0], [3.0, 5.0, 1.0], PT).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trace_starting_inside_does_not_block() {
|
||||
let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]);
|
||||
// Start mitten im Brush → kein Hit (sonst säße man fest).
|
||||
assert!(w.trace([1.0, 1.0, 1.0], [5.0, 1.0, 1.0], PT).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aabb_expands_by_half_extents() {
|
||||
let w = from_brushes(vec![aabb_brush([0.0, 0.0, 0.0], [2.0, 2.0, 2.0])]);
|
||||
// Box mit halber Breite 0.5: Kontakt schon bei x≈-0.5 statt 0.
|
||||
let h = w.trace([-2.0, 1.0, 1.0], [2.0, 1.0, 1.0], [0.5, 0.5, 0.5]).unwrap();
|
||||
// Weg 4, Kontakt bei x≈-0.5 → frac≈(−0.5−(−2))/4 = 0.375.
|
||||
assert!((h.frac - 0.375).abs() < 0.02, "frac={}", h.frac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_brushes_from_map() {
|
||||
// Ein achsenparalleler Quader-Brush (Quake-Koords).
|
||||
let src = r#"
|
||||
{
|
||||
"classname" "worldspawn"
|
||||
{
|
||||
( 0 0 0 ) ( 0 1 0 ) ( 0 0 1 ) t 0 0 0 1 1
|
||||
( 0 0 0 ) ( 0 0 1 ) ( 1 0 0 ) t 0 0 0 1 1
|
||||
( 0 0 0 ) ( 1 0 0 ) ( 0 1 0 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 64 65 64 ) ( 65 64 64 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 65 64 64 ) ( 64 64 65 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 64 64 65 ) ( 64 65 64 ) t 0 0 0 1 1
|
||||
}
|
||||
}
|
||||
"#;
|
||||
let m = map::parse(src);
|
||||
let w = CollisionWorld::build(&m);
|
||||
assert_eq!(w.brushes.len(), 1);
|
||||
assert_eq!(w.brushes[0].planes.len(), 6);
|
||||
// Engine-Koords des Quake-Würfels [0,64]³: x[0,2], y[0,2], z[-2,0]
|
||||
// (Drehung (x,z,−y)·1/32). Strahl von außerhalb (−X) hindurch.
|
||||
let h = w.trace([-1.0, 1.0, -1.0], [3.0, 1.0, -1.0], PT);
|
||||
assert!(h.is_some(), "Strahl sollte den Brush treffen");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Owner des dispatch-relevanten Spielzustands und der Borrow-View, durch
|
||||
//! die Signal-Actions und Ink-Story-Steps darauf zugreifen.
|
||||
//!
|
||||
//! `Game` hält die Slots, die Actions lesen oder mutieren dürfen.
|
||||
//! `ActionCtx` ist der schmale `&mut`-View; er wird über `Game::action_ctx()`
|
||||
//! konstruiert und durch die Dispatch-Pipeline gereicht.
|
||||
//!
|
||||
//! Verben, deren Ziel-Subsystem (Szene, Audio, Renderer) noch nicht
|
||||
//! existiert oder den Kern nichts angeht, produzieren [`Action`]-Werte
|
||||
//! statt direkt zu wirken. Frontends konsumieren die Queue: die CLI druckt
|
||||
//! sie, die Engine führt sie später aus. So bleibt der Kern headless
|
||||
//! testbar und kennt keines der Subsysteme.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::engine::ink;
|
||||
use crate::engine::kv::Store;
|
||||
|
||||
/// Signal-Table aus `assets/signals.toml`: Signal-Name → Action-Strings.
|
||||
/// Geladen/geparst von [`crate::engine::signals`]; definiert ist der Typ
|
||||
/// hier beim Zustand, damit `game` ← `signals` eine Einbahnstraße bleibt.
|
||||
pub type Signals = HashMap<String, Vec<String>>;
|
||||
|
||||
/// Action, die der Kern nicht selbst ausführt, sondern ans Frontend
|
||||
/// weiterreicht. Die Queue in `Game::actions` gehört nach jedem Dispatch
|
||||
/// geleert (konsumiert).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Action {
|
||||
/// Instance unsichtbar + kollisionslos + nicht mehr interagierbar machen.
|
||||
HideObject(String),
|
||||
/// WAV unter `assets/audio/<name>` als SFX abspielen.
|
||||
PlaySound(String),
|
||||
/// Anzeige-/Eingabemodus wechseln (Spiel, Flycam, Menü). Vom Frontend
|
||||
/// auf seinen `Mode` gemappt; der Dialog-Modus ist hier bewusst nicht
|
||||
/// wählbar — der entsteht nur aus dem Story-Ablauf.
|
||||
SetMode(ModeTarget),
|
||||
}
|
||||
|
||||
/// Frontend-neutrales Ziel eines Moduswechsels. Spiegelt die nicht-Dialog-
|
||||
/// Varianten von `session::Mode`, ohne dass der Kern die rich `Mode`-Daten
|
||||
/// (Dialog-State) kennen muss. So bleibt `game` ← `session` eine Einbahnstraße.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModeTarget {
|
||||
/// First-Person-Spiel.
|
||||
Play,
|
||||
/// Noclip-Debug-Flycam.
|
||||
Free,
|
||||
/// Menü (pausiert die Welt, gibt die Maus frei).
|
||||
Menu,
|
||||
}
|
||||
|
||||
impl ModeTarget {
|
||||
/// `play`/`free`/`menu` (case-insensitiv) → Ziel; sonst `None`.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"play" => Some(Self::Play),
|
||||
"free" => Some(Self::Free),
|
||||
"menu" => Some(Self::Menu),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Kanonischer Name fürs Echo/Logging.
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Play => "play",
|
||||
Self::Free => "free",
|
||||
Self::Menu => "menu",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Game {
|
||||
pub kv: Store,
|
||||
pub signals: Signals,
|
||||
pub story: Option<ink::Story>,
|
||||
pub actions: Vec<Action>,
|
||||
}
|
||||
|
||||
pub struct ActionCtx<'a> {
|
||||
pub kv: &'a mut Store,
|
||||
pub signals: &'a Signals,
|
||||
pub story: &'a mut Option<ink::Story>,
|
||||
pub actions: &'a mut Vec<Action>,
|
||||
/// Auslöser-Identifier: bei Objekt-Interaktion der volle Instance-Name
|
||||
/// (mit Blender-Suffix, z.B. `Mushroom.005`). `$self` in Action-Args
|
||||
/// wird damit substituiert. `None` bei Signalen ohne Quell-Instance
|
||||
/// (z.B. `init`, Story-Tags).
|
||||
pub instance_name: Option<String>,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn new(signals: Signals) -> Self {
|
||||
Self { kv: Store::new(), signals, story: None, actions: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn action_ctx(&mut self, instance_name: Option<String>) -> ActionCtx<'_> {
|
||||
ActionCtx {
|
||||
kv: &mut self.kv,
|
||||
signals: &self.signals,
|
||||
story: &mut self.story,
|
||||
actions: &mut self.actions,
|
||||
instance_name,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Dünner Adapter um die `bladeink`-Crate, gekoppelt an unseren `kv::Store`.
|
||||
//!
|
||||
//! Bei jedem `cont()` läuft ein bidirektionaler Sync:
|
||||
//! - vor `continue`: KV-Werte ins `variables_state` der Story spiegeln
|
||||
//! (Skript sieht aktuelle Spielvariablen)
|
||||
//! - nach `continue`: alle KV-Keys, die das Skript kennt, zurücklesen
|
||||
//! (Ink-`~ var = …`-Writes landen im KV)
|
||||
//!
|
||||
//! Variablen die der KV kennt, das Skript aber nicht als `VAR` deklariert,
|
||||
//! lehnt bladeink beim `set_variable` ab — wir ignorieren das still.
|
||||
//! Variablen die nur im Skript existieren, werden beim Read-Back ignoriert
|
||||
//! (KV bleibt minimal: nur was Rust kennt).
|
||||
|
||||
use std::fs::read_to_string;
|
||||
|
||||
use bladeink::story::Story as BladeStory;
|
||||
|
||||
use crate::engine::kv::Store;
|
||||
|
||||
pub enum StoryState {
|
||||
Text(String),
|
||||
Choice { prompt: String, options: Vec<String> },
|
||||
End,
|
||||
}
|
||||
|
||||
pub struct Story {
|
||||
inner: BladeStory,
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
impl Story {
|
||||
pub fn load(path: &str, kv: &Store) -> Self {
|
||||
let src = read_to_string(path)
|
||||
.unwrap_or_else(|e| panic!("ink load {}: {}", path, e));
|
||||
let inner = BladeStory::new(&src)
|
||||
.unwrap_or_else(|e| panic!("ink parse {}: {:?}", path, e));
|
||||
let mut s = Self { inner, tags: Vec::new() };
|
||||
s.sync_from_kv(kv);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn take_tags(&mut self) -> Vec<String> { std::mem::take(&mut self.tags) }
|
||||
|
||||
pub fn cont(&mut self, kv: &mut Store) -> StoryState {
|
||||
self.sync_from_kv(kv);
|
||||
|
||||
// continue_maximally ruft intern reset_output() vor jedem cont() —
|
||||
// damit überschriebe der Tag der letzten Zeile alle vorherigen.
|
||||
// Wir loopen selbst und sammeln Tags nach jedem Schritt ein, sonst
|
||||
// gehen z.B. `# set has_key true` am Knoten-Anfang bei mehrzeiligem
|
||||
// Text verloren.
|
||||
let mut text = String::new();
|
||||
while self.inner.can_continue() {
|
||||
let chunk = self.inner.cont()
|
||||
.unwrap_or_else(|e| panic!("ink cont: {:?}", e));
|
||||
text.push_str(&chunk);
|
||||
if let Ok(t) = self.inner.get_current_tags() {
|
||||
self.tags.extend(t);
|
||||
}
|
||||
}
|
||||
let text = text.trim().to_string();
|
||||
|
||||
self.sync_to_kv(kv);
|
||||
|
||||
let choices = self.inner.get_current_choices();
|
||||
if !choices.is_empty() {
|
||||
let options = choices.iter().map(|c| c.text.clone()).collect();
|
||||
return StoryState::Choice { prompt: text, options };
|
||||
}
|
||||
if !text.is_empty() { return StoryState::Text(text); }
|
||||
StoryState::End
|
||||
}
|
||||
|
||||
pub fn choose(&mut self, i: usize) {
|
||||
self.inner.choose_choice_index(i)
|
||||
.unwrap_or_else(|e| panic!("ink choose {}: {:?}", i, e));
|
||||
}
|
||||
|
||||
fn sync_from_kv(&mut self, kv: &Store) {
|
||||
for (name, value) in kv {
|
||||
// set_variable schlägt fehl wenn der Name kein deklariertes
|
||||
// VAR im Skript ist — still ignorieren.
|
||||
let _ = self.inner.set_variable(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_to_kv(&self, kv: &mut Store) {
|
||||
let keys: Vec<String> = kv.keys().cloned().collect();
|
||||
for name in keys {
|
||||
if let Some(v) = self.inner.get_variable(&name) {
|
||||
kv.insert(name, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Persistenter Game-State Key-Value-Store.
|
||||
//!
|
||||
//! Single Source of Truth für alle Spielvariablen (Quest-Flags, Inventar-
|
||||
//! Counter, Story-Beats). Ink-Skripte haben eigene `variables_state`-Stores;
|
||||
//! die werden bei jedem `cont()` mit diesem KV synchronisiert (siehe
|
||||
//! [`crate::engine::ink`]). Damit der KV authoritativ bleibt: externe Actions
|
||||
//! (`set`, `inc`, `clear`) ändern *immer* den KV, niemals direkt die Story.
|
||||
//!
|
||||
//! Wir nutzen `bladeink::ValueType` als Value, damit die Sync-Konvertierung
|
||||
//! gratis ist (kein Mapping zwischen Rust- und Ink-Typen nötig).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bladeink::value_type::ValueType;
|
||||
|
||||
pub type Store = HashMap<String, ValueType>;
|
||||
|
||||
/// Parst einen Action-Arg-String zu einem `ValueType`. Reihenfolge:
|
||||
/// `true`/`false` → Bool, dann i32, dann f32, sonst String.
|
||||
pub fn parse_value(s: &str) -> ValueType {
|
||||
let s = s.trim();
|
||||
match s {
|
||||
"true" => return ValueType::Bool(true),
|
||||
"false" => return ValueType::Bool(false),
|
||||
_ => {}
|
||||
}
|
||||
if let Ok(i) = s.parse::<i32>() { return ValueType::Int(i); }
|
||||
if let Ok(f) = s.parse::<f32>() { return ValueType::Float(f); }
|
||||
ValueType::new(s)
|
||||
}
|
||||
|
||||
/// Menschenlesbare Darstellung für REPL-Dumps und Debug-Logs.
|
||||
pub fn format_value(v: &ValueType) -> String {
|
||||
match v {
|
||||
ValueType::Bool(b) => b.to_string(),
|
||||
ValueType::Int(i) => i.to_string(),
|
||||
ValueType::Float(f) => f.to_string(),
|
||||
other => other.coerce_to_string().unwrap_or_else(|_| "<?>".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_set(args: &str, store: &mut Store) {
|
||||
let Some((name, value)) = args.split_once(char::is_whitespace) else {
|
||||
eprintln!("[kv] set: expected 'name value', got {:?}", args);
|
||||
return;
|
||||
};
|
||||
store.insert(name.trim().to_string(), parse_value(value));
|
||||
}
|
||||
|
||||
pub fn apply_inc(args: &str, store: &mut Store) {
|
||||
let (name, delta) = args.split_once(char::is_whitespace).unwrap_or((args, "1"));
|
||||
let name = name.trim();
|
||||
let delta = delta.trim().parse::<i32>().unwrap_or(1);
|
||||
let cur = store.get(name).and_then(|v| v.coerce_to_int().ok()).unwrap_or(0);
|
||||
store.insert(name.to_string(), ValueType::Int(cur + delta));
|
||||
}
|
||||
|
||||
pub fn apply_clear(args: &str, store: &mut Store) {
|
||||
store.remove(args.trim());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_value_types() {
|
||||
assert!(matches!(parse_value("true"), ValueType::Bool(true)));
|
||||
assert!(matches!(parse_value("false"), ValueType::Bool(false)));
|
||||
assert!(matches!(parse_value("42"), ValueType::Int(42)));
|
||||
assert!(matches!(parse_value("-7"), ValueType::Int(-7)));
|
||||
assert!(matches!(parse_value("1.5"), ValueType::Float(f) if f == 1.5));
|
||||
assert!(matches!(parse_value("hallo"), ValueType::String(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_inc_clear_roundtrip() {
|
||||
let mut s = Store::new();
|
||||
apply_set("clicks 3", &mut s);
|
||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 3);
|
||||
|
||||
apply_inc("clicks", &mut s);
|
||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 4);
|
||||
|
||||
apply_inc("clicks -2", &mut s);
|
||||
assert_eq!(s["clicks"].coerce_to_int().unwrap(), 2);
|
||||
|
||||
// inc auf unbekannten Key startet bei 0
|
||||
apply_inc("fresh 5", &mut s);
|
||||
assert_eq!(s["fresh"].coerce_to_int().unwrap(), 5);
|
||||
|
||||
apply_clear("clicks", &mut s);
|
||||
assert!(!s.contains_key("clicks"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_without_value_is_noop() {
|
||||
let mut s = Store::new();
|
||||
apply_set("only_name", &mut s);
|
||||
assert!(s.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Quake-`.map`-Parser (Standard-Format, wie TrenchBroom es mit dem
|
||||
//! Generic-Game exportiert).
|
||||
//!
|
||||
//! Struktur: eine Datei ist eine Liste von Entities `{...}`. Eine Entity
|
||||
//! hält Key/Value-Properties (`"key" "value"`) und null oder mehr Brushes
|
||||
//! `{...}`. Ein Brush ist eine Liste von Faces; jede Face ist eine Ebene
|
||||
//! aus drei Punkten plus Textur-Ausrichtung:
|
||||
//!
|
||||
//! ( x y z ) ( x y z ) ( x y z ) TEXTUR offX offY rot scaleX scaleY
|
||||
//!
|
||||
//! Hier wird nur geparst — Geometrie bleibt im Quake-Koordinatensystem
|
||||
//! (Z-up, Brush = Schnitt der Halbräume); die Umrechnung ins Engine-System
|
||||
//! und der Halbraum-Schnitt sind Sache des Konsumenten (render::brush).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
pub struct Map {
|
||||
pub entities: Vec<Entity>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Entity {
|
||||
pub props: HashMap<String, String>,
|
||||
pub brushes: Vec<Brush>,
|
||||
}
|
||||
|
||||
impl Entity {
|
||||
// Genutzt für Spawn-Auflösung (info_player_start) und künftige
|
||||
// Entity-/Signal-Bindung.
|
||||
pub fn classname(&self) -> Option<&str> {
|
||||
self.props.get("classname").map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Brush {
|
||||
pub faces: Vec<Face>,
|
||||
}
|
||||
|
||||
/// Eine Brush-Face: Ebene aus drei Punkten (Quake-Reihenfolge, im
|
||||
/// Uhrzeigersinn von vorn gesehen) plus Textur-Ausrichtung. Die
|
||||
/// Ausrichtungsfelder werden erst vom Textur-Schritt gebraucht.
|
||||
pub struct Face {
|
||||
pub plane: [[f32; 3]; 3],
|
||||
pub texture: String,
|
||||
#[allow(dead_code)]
|
||||
pub offset: [f32; 2],
|
||||
#[allow(dead_code)]
|
||||
pub rotation: f32,
|
||||
#[allow(dead_code)]
|
||||
pub scale: [f32; 2],
|
||||
}
|
||||
|
||||
/// Quake-Einheiten pro Engine-Einheit (32 ≈ klassische „1 Meter"-Annahme).
|
||||
/// Single Source für Render- (render::brush) und Collision-Pfad
|
||||
/// (engine::collision), damit beide denselben Maßstab benutzen.
|
||||
pub const MAP_SCALE: f32 = 1.0 / 32.0;
|
||||
|
||||
/// Quake-Koords (Z-up) → Engine-Koords (Y-up, Blick −Z), skaliert:
|
||||
/// `(x, y, z) → (x, z, −y) · MAP_SCALE`. Die Drehung erhält die Orientierung
|
||||
/// (det +1) — nach außen zeigende Normalen bleiben außen.
|
||||
pub fn to_engine(p: [f32; 3]) -> [f32; 3] {
|
||||
[p[0] * MAP_SCALE, p[2] * MAP_SCALE, -p[1] * MAP_SCALE]
|
||||
}
|
||||
|
||||
pub fn load(path: &str) -> Map {
|
||||
let src = read_to_string(path).unwrap_or_else(|e| panic!("map load {path}: {e}"));
|
||||
parse(&src)
|
||||
}
|
||||
|
||||
pub fn parse(src: &str) -> Map {
|
||||
let mut entities = Vec::new();
|
||||
let mut cur_entity: Option<Entity> = None;
|
||||
let mut cur_brush: Option<Brush> = None;
|
||||
|
||||
for raw in src.lines() {
|
||||
// Kommentar (`// …`) abschneiden, dann trimmen.
|
||||
let line = match raw.split_once("//") {
|
||||
Some((code, _)) => code.trim(),
|
||||
None => raw.trim(),
|
||||
};
|
||||
if line.is_empty() { continue; }
|
||||
|
||||
match line {
|
||||
"{" => {
|
||||
// Erstes `{` öffnet eine Entity, ein weiteres einen Brush.
|
||||
if cur_entity.is_none() {
|
||||
cur_entity = Some(Entity::default());
|
||||
} else {
|
||||
cur_brush = Some(Brush { faces: Vec::new() });
|
||||
}
|
||||
}
|
||||
"}" => {
|
||||
if let Some(brush) = cur_brush.take() {
|
||||
cur_entity.as_mut().unwrap().brushes.push(brush);
|
||||
} else if let Some(entity) = cur_entity.take() {
|
||||
entities.push(entity);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(brush) = cur_brush.as_mut() {
|
||||
if let Some(face) = parse_face(line) {
|
||||
brush.faces.push(face);
|
||||
}
|
||||
} else if let Some(entity) = cur_entity.as_mut() {
|
||||
if let Some((k, v)) = parse_property(line) {
|
||||
entity.props.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Map { entities }
|
||||
}
|
||||
|
||||
/// `"key" "value"` — Wert darf leer sein.
|
||||
fn parse_property(line: &str) -> Option<(String, String)> {
|
||||
let mut it = line.split('"');
|
||||
it.next()?; // vor dem ersten "
|
||||
let key = it.next()?; // key
|
||||
it.next()?; // zwischen den Paaren
|
||||
let val = it.next()?; // value
|
||||
Some((key.to_string(), val.to_string()))
|
||||
}
|
||||
|
||||
/// `( x y z ) ( x y z ) ( x y z ) TEX offX offY rot sx sy`
|
||||
fn parse_face(line: &str) -> Option<Face> {
|
||||
let t: Vec<&str> = line.split_whitespace().collect();
|
||||
let mut i = 0;
|
||||
let mut plane = [[0.0f32; 3]; 3];
|
||||
for p in 0..3 {
|
||||
if *t.get(i)? != "(" { return None; }
|
||||
for k in 0..3 {
|
||||
plane[p][k] = t.get(i + 1 + k)?.parse().ok()?;
|
||||
}
|
||||
if *t.get(i + 4)? != ")" { return None; }
|
||||
i += 5;
|
||||
}
|
||||
let texture = (*t.get(i)?).to_string();
|
||||
let nums: Option<Vec<f32>> = t[i + 1..i + 6].iter().map(|s| s.parse().ok()).collect();
|
||||
let n = nums?;
|
||||
Some(Face {
|
||||
plane,
|
||||
texture,
|
||||
offset: [n[0], n[1]],
|
||||
rotation: n[2],
|
||||
scale: [n[3], n[4]],
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SAMPLE: &str = r#"
|
||||
// entity 0
|
||||
{
|
||||
"classname" "worldspawn"
|
||||
"wad" ""
|
||||
// brush 0
|
||||
{
|
||||
( -48 -64 -48 ) ( -48 -63 -48 ) ( -48 -64 -47 ) placeholder 32 -32 0 1 1
|
||||
( -48 -64 -48 ) ( -48 -64 -47 ) ( -47 -64 -48 ) placeholder -16 -32 0 1 1
|
||||
( -48 -64 -48 ) ( -47 -64 -48 ) ( -48 -63 -48 ) placeholder -16 -32 0 1 1
|
||||
( 80 64 -16 ) ( 80 65 -16 ) ( 81 64 -16 ) placeholder -16 -32 0 1 1
|
||||
( 80 64 -16 ) ( 81 64 -16 ) ( 80 64 -15 ) placeholder -16 -32 0 1 1
|
||||
( 80 64 -16 ) ( 80 64 -15 ) ( 80 65 -16 ) placeholder 32 -32 0 1 1
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn parses_entity_brush_and_face() {
|
||||
let m = parse(SAMPLE);
|
||||
assert_eq!(m.entities.len(), 1);
|
||||
let e = &m.entities[0];
|
||||
assert_eq!(e.classname(), Some("worldspawn"));
|
||||
assert_eq!(e.props.get("wad").map(String::as_str), Some(""));
|
||||
assert_eq!(e.brushes.len(), 1);
|
||||
let b = &e.brushes[0];
|
||||
assert_eq!(b.faces.len(), 6);
|
||||
|
||||
let f = &b.faces[0];
|
||||
assert_eq!(f.texture, "placeholder");
|
||||
assert_eq!(f.plane[0], [-48.0, -64.0, -48.0]);
|
||||
assert_eq!(f.plane[2], [-48.0, -64.0, -47.0]);
|
||||
assert_eq!(f.offset, [32.0, -32.0]);
|
||||
assert_eq!(f.scale, [1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Headless-Kern: alles, was läuft, ohne dass ein Frontend existiert.
|
||||
//!
|
||||
//! Schicht-Regel: `engine` kennt keine Frontends. Nach außen kommuniziert
|
||||
//! der Kern nur über Rückgabewerte (`StoryState`, Tags) und die
|
||||
//! [`game::Action`]-Queue — niemals über Aufrufe in Frontend-Module.
|
||||
//! Frontends (CLI-REPL jetzt, Renderer später) sind Geschwister-Module,
|
||||
//! die `engine` konsumieren; die Richtung kehrt sich nie um.
|
||||
//!
|
||||
//! Interner Abhängigkeitsgraph (azyklisch, Pfeil = „benutzt"):
|
||||
//!
|
||||
//! kv ← ink ← game ← story_ctrl ← signals
|
||||
//! ↑ ↑
|
||||
//! kv assets
|
||||
//!
|
||||
//! Bitte azyklisch halten: neue Querverbindungen lieber über Rückgabewerte
|
||||
//! an den Aufrufer lösen (so wie story_ctrl Tags zurückgibt, statt selbst
|
||||
//! signals::dispatch zu rufen).
|
||||
//!
|
||||
//! `map` und `tga` sind reine Decoder (Bytes → owned Daten, hängen an
|
||||
//! nichts) — die geteilte Heimat für Format-Dekodierung, die jedes Frontend
|
||||
//! per Pull konsumiert. `map` ist zugleich der Anfang des headless
|
||||
//! Datenmodells (Phase 2 des Renderer-Plans).
|
||||
//!
|
||||
//! `player` ist die First-Person-Physik, aus der der Renderer seine View
|
||||
//! ableitet; `collision` (hängt an `map`) liefert ihr die Brush-Welt für den
|
||||
//! Swept-AABB-Trace in `player::step`. Beide bleiben headless.
|
||||
|
||||
pub mod assets;
|
||||
pub mod collision;
|
||||
pub mod game;
|
||||
pub mod ink;
|
||||
pub mod kv;
|
||||
pub mod map;
|
||||
pub mod player;
|
||||
pub mod signals;
|
||||
pub mod story_ctrl;
|
||||
pub mod tga;
|
||||
@@ -0,0 +1,479 @@
|
||||
//! First-Person-Player: headless Bewegungsphysik (Stufe 1).
|
||||
//!
|
||||
//! Bewusst frontend- und GPU-frei — wie der Rest von `engine`. Der Renderer
|
||||
//! *leitet* seine View aus dem Player ab (`Mat4::view(pos + eye, yaw, pitch)`),
|
||||
//! der Player kennt den Renderer nie. Damit bleibt die Physik im Terminal
|
||||
//! testbar, und die Schichtrichtung (`engine ← render`) kehrt sich nicht um.
|
||||
//!
|
||||
//! Keine neuen Dependencies: `step` braucht nur `sin_cos` und Komponenten-
|
||||
//! Arithmetik. Vektor-Helfer werden nicht geteilt (brush.rs hält seine eigenen
|
||||
//! privat) — erst wenn Stufe 2 (Brush-Collision) echte dot/cross/normalize an
|
||||
//! mehreren Stellen braucht, lohnt ein gemeinsames Modul.
|
||||
//!
|
||||
//! Collision (Stufe 3): `step` sweept die Spieler-AABB gegen die
|
||||
//! [`CollisionWorld`] (Brushes) und gleitet an Treffern entlang (move-and-slide).
|
||||
//! Getract wird der Box-Mittelpunkt (Fußpunkt + halbe Höhe), danach geht es
|
||||
//! zurück auf den Fußpunkt. Bodenkontakt fällt aus nach oben zeigenden
|
||||
//! Trefferflächen ab.
|
||||
//!
|
||||
//! Konvention wie `render::math::view`/`camera.rs`: yaw=0 blickt −Z, positiver
|
||||
//! Yaw dreht nach links, positiver Pitch hebt den Blick. Die Bewegung ist
|
||||
//! horizontal aus dem Yaw (ohne Pitch) — Hochschauen lässt einen nicht abheben.
|
||||
|
||||
use crate::engine::collision::CollisionWorld;
|
||||
|
||||
/// Augenhöhe über dem Fußpunkt (units). Der Renderer setzt die Kamera auf
|
||||
/// `pos + [0, EYE_HEIGHT, 0]`. 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE).
|
||||
pub const EYE_HEIGHT: f32 = 1.6;
|
||||
|
||||
/// Fallbeschleunigung (units/s²). Über realem g (9.81) für knackiges
|
||||
/// Spielgefühl — wie die meisten Shooter. Tuning-Wert.
|
||||
const GRAVITY: f32 = 20.0;
|
||||
/// Laufgeschwindigkeit (units/s). 1 Engine-Unit ≈ 1 m (siehe map::MAP_SCALE).
|
||||
const WALK_SPEED: f32 = 5.0;
|
||||
/// Absprunggeschwindigkeit (units/s) — bestimmt die Sprunghöhe. Tuning-Wert.
|
||||
const JUMP_SPEED: f32 = 7.0;
|
||||
/// Knapp unter 90°: hält den Blick aus der Senkrechten (wie `camera.rs`).
|
||||
const PITCH_LIMIT: f32 = 1.55;
|
||||
/// Halbmaße der Spieler-AABB (units): 0.6 m breit/tief, 1.8 m hoch.
|
||||
const HALF_EXTENTS: [f32; 3] = [0.3, 0.9, 0.3];
|
||||
/// Trefferflächen mit Normalen-Y darüber gelten als Boden (~45°-Rampen ok).
|
||||
const GROUND_NORMAL_Y: f32 = 0.7;
|
||||
/// Slide-Iterationen pro Schritt (Wände, Ecken, Boden zugleich).
|
||||
const MAX_SLIDES: u32 = 4;
|
||||
/// Wie weit der Ground-Snap nach unten sucht (units). Überbrückt Stufen,
|
||||
/// abwärts-Slopes und Brush-Nähte, ohne echte Abgründe zu „verschlucken".
|
||||
const SNAP_DIST: f32 = 0.5;
|
||||
/// Maximale Stufenhöhe, die der Spieler ohne Sprung übersteigt (units).
|
||||
const STEP_HEIGHT: f32 = 0.5;
|
||||
|
||||
pub struct Player {
|
||||
/// Fußpunkt in Engine-Koords (Y-up).
|
||||
pub pos: [f32; 3],
|
||||
/// Geschwindigkeit in units/s (Y trägt Gravitation/Sprung).
|
||||
pub vel: [f32; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
/// Steht auf Boden? Setzt der Boden-/Collision-Schritt; Gattung für Sprung.
|
||||
pub grounded: bool,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
pub fn new(pos: [f32; 3]) -> Self {
|
||||
Self { pos, vel: [0.0; 3], yaw: 0.0, pitch: 0.0, grounded: false }
|
||||
}
|
||||
|
||||
/// Roh-Maus-Delta (Pixel) → Yaw/Pitch, identisch zu `camera::apply_mouse`,
|
||||
/// damit Bewegung und Bild dieselbe Konvention teilen.
|
||||
pub fn look(&mut self, dx: f32, dy: f32, sens: f32) {
|
||||
self.yaw -= dx * sens;
|
||||
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||
}
|
||||
|
||||
/// Einen Physikschritt integrieren. `world` ist die Brush-Kollisionswelt,
|
||||
/// `fwd`/`right` Tastenachsen in [-1, 1], `jump` ein Flankensignal (true =
|
||||
/// Sprungtaste diesen Frame), `dt` die Frame-Zeit in Sekunden.
|
||||
pub fn step(&mut self, world: &CollisionWorld, fwd: f32, right: f32, jump: bool, dt: f32) {
|
||||
let was_grounded = self.grounded;
|
||||
|
||||
// Horizontale Basis aus dem Yaw (ohne Pitch): forward = -Z bei yaw=0,
|
||||
// right = +X bei yaw=0 — exakt wie `camera::forward`/`right` flach.
|
||||
let (sy, cy) = self.yaw.sin_cos();
|
||||
let f = [-sy, -cy]; // (x, z)
|
||||
let r = [ cy, -sy]; // (x, z)
|
||||
|
||||
// Wunschgeschwindigkeit sofort setzen (ohne Beschleunigung/Reibung —
|
||||
// das ist Feintuning für später). Volle Luftkontrolle, ebenfalls vorerst.
|
||||
self.vel[0] = (f[0] * fwd + r[0] * right) * WALK_SPEED;
|
||||
self.vel[2] = (f[1] * fwd + r[1] * right) * WALK_SPEED;
|
||||
|
||||
// Sprung vor der Gravitation, nur vom Boden (Vorframe-`grounded`) —
|
||||
// kein Doppelsprung.
|
||||
let jumped = jump && was_grounded;
|
||||
if jumped {
|
||||
self.vel[1] = JUMP_SPEED;
|
||||
self.grounded = false;
|
||||
}
|
||||
self.vel[1] -= GRAVITY * dt;
|
||||
|
||||
self.move_and_slide(world, dt);
|
||||
|
||||
// Ground-Snap: war der Spieler eben noch am Boden, ist nicht gesprungen
|
||||
// und steht nach dem Zug knapp in der Luft (abwärts-Slope, Stufe runter,
|
||||
// Brush-Naht), ziehen wir ihn auf den Boden darunter — so klebt er über
|
||||
// Nähte hinweg, statt kurz luftig darüber zu schweben. Nicht beim
|
||||
// Aufsteigen (vel.y > 0), damit Rampen-Absprünge erhalten bleiben.
|
||||
if was_grounded && !jumped && !self.grounded && self.vel[1] <= 0.0 {
|
||||
self.snap_to_ground(world);
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Box senkrecht bis zu [`SNAP_DIST`] nach unten tracen; trifft sie
|
||||
/// dabei echten Boden, den Spieler dorthin heften und `grounded` setzen.
|
||||
fn snap_to_ground(&mut self, world: &CollisionWorld) {
|
||||
let half = HALF_EXTENTS;
|
||||
let center = [self.pos[0], self.pos[1] + half[1], self.pos[2]];
|
||||
let down = [center[0], center[1] - SNAP_DIST, center[2]];
|
||||
let Some(hit) = world.trace(center, down, half) else { return; };
|
||||
if hit.normal[1] <= GROUND_NORMAL_Y { return; } // nur Boden, keine Wand
|
||||
self.pos[1] = (center[1] - SNAP_DIST * hit.frac) - half[1];
|
||||
self.grounded = true;
|
||||
if self.vel[1] < 0.0 { self.vel[1] = 0.0; }
|
||||
}
|
||||
|
||||
/// Den Box-Mittelpunkt gegen die Welt bewegen, mit Step-up. Aktualisiert
|
||||
/// `pos`, `vel` und `grounded`.
|
||||
///
|
||||
/// Zuerst ein flacher Slide. Steht der Spieler am Boden, wird zusätzlich
|
||||
/// der Step-up probiert: hoch um [`STEP_HEIGHT`], horizontal sliden, wieder
|
||||
/// runter auf Boden. Kommt das horizontal weiter als der flache Zug und
|
||||
/// endet auf begehbarem Boden, gewinnt es. Das hebt die flache Box-
|
||||
/// Unterkante über Brush-Nähte (Slope-Kanten, Stufen), an denen sie sonst
|
||||
/// als „Wand" verkantet — der Grund, warum man ohne Step-up weder auf eine
|
||||
/// Slope hinauf- noch (wegen der vertikalen Prismen-Seitenfläche) hinabkam.
|
||||
fn move_and_slide(&mut self, world: &CollisionWorld, dt: f32) {
|
||||
let half = HALF_EXTENTS;
|
||||
let was_grounded = self.grounded; // in step() vor dem Sprung gesetzt
|
||||
let start = [self.pos[0], self.pos[1] + half[1], self.pos[2]];
|
||||
|
||||
let (flat_c, flat_v, flat_g) = slide(world, start, self.vel, dt);
|
||||
let (mut best_c, mut best_v, mut best_g) = (flat_c, flat_v, flat_g);
|
||||
|
||||
if was_grounded {
|
||||
let (up, _) = trace_step(world, start, [0.0, STEP_HEIGHT, 0.0]);
|
||||
let (stepped, step_v, _) = slide(world, up, self.vel, dt);
|
||||
let down_dist = (up[1] - start[1]) + STEP_HEIGHT;
|
||||
let (landed, normal) = trace_step(world, stepped, [0.0, -down_dist, 0.0]);
|
||||
let on_ground = normal.is_some_and(|n| n[1] > GROUND_NORMAL_Y);
|
||||
if on_ground && horiz_dist2(landed, start) > horiz_dist2(flat_c, start) + 1e-6 {
|
||||
best_c = landed;
|
||||
best_v = step_v;
|
||||
best_g = true;
|
||||
}
|
||||
}
|
||||
|
||||
self.pos = [best_c[0], best_c[1] - half[1], best_c[2]];
|
||||
self.vel = best_v;
|
||||
self.grounded = best_g;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reine Slide-Bewegung des Box-Mittelpunkts entlang `vel·dt` (keine Stufen):
|
||||
/// bis zu [`MAX_SLIDES`] Iterationen, an Treffern entlanggleitend. Gibt
|
||||
/// Endpunkt, Restgeschwindigkeit und Bodenkontakt zurück.
|
||||
fn slide(world: &CollisionWorld, mut center: [f32; 3], mut vel: [f32; 3], dt: f32) -> ([f32; 3], [f32; 3], bool) {
|
||||
let half = HALF_EXTENTS;
|
||||
let mut grounded = false;
|
||||
let mut remaining = dt;
|
||||
for _ in 0..MAX_SLIDES {
|
||||
if remaining <= 0.0 { break; }
|
||||
let disp = [vel[0] * remaining, vel[1] * remaining, vel[2] * remaining];
|
||||
let end = [center[0] + disp[0], center[1] + disp[1], center[2] + disp[2]];
|
||||
let Some(hit) = world.trace(center, end, half) else {
|
||||
center = end; // freie Bahn — Rest des Schritts ausführen
|
||||
break;
|
||||
};
|
||||
for i in 0..3 { center[i] += disp[i] * hit.frac; }
|
||||
remaining *= 1.0 - hit.frac;
|
||||
if hit.normal[1] > GROUND_NORMAL_Y { grounded = true; }
|
||||
let into = vel[0] * hit.normal[0] + vel[1] * hit.normal[1] + vel[2] * hit.normal[2];
|
||||
for i in 0..3 { vel[i] -= hit.normal[i] * into; }
|
||||
}
|
||||
(center, vel, grounded)
|
||||
}
|
||||
|
||||
/// Den Mittelpunkt um `delta` (ein Achsen-Versatz) bewegen, an Geometrie
|
||||
/// gestoppt. Gibt den erreichten Punkt und — falls gestoppt — die Treffer-
|
||||
/// Normale zurück.
|
||||
fn trace_step(world: &CollisionWorld, from: [f32; 3], delta: [f32; 3]) -> ([f32; 3], Option<[f32; 3]>) {
|
||||
let to = [from[0] + delta[0], from[1] + delta[1], from[2] + delta[2]];
|
||||
match world.trace(from, to, HALF_EXTENTS) {
|
||||
None => (to, None),
|
||||
Some(hit) => (
|
||||
[from[0] + delta[0] * hit.frac, from[1] + delta[1] * hit.frac, from[2] + delta[2] * hit.frac],
|
||||
Some(hit.normal),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Quadrierte horizontale Distanz (X/Z) — fürs „weiter gekommen?"-Vergleichen.
|
||||
fn horiz_dist2(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
(a[0] - b[0]).powi(2) + (a[2] - b[2]).powi(2)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::engine::collision::{self, CollisionWorld};
|
||||
|
||||
/// Leere Welt (kein Boden) → freier Fall, gut für Luft-Tests.
|
||||
fn void() -> CollisionWorld {
|
||||
collision::from_brushes(vec![])
|
||||
}
|
||||
|
||||
/// Großer Boden-Brush mit Oberkante bei y=0.
|
||||
fn floor() -> CollisionWorld {
|
||||
collision::from_brushes(vec![
|
||||
collision::aabb_brush([-50.0, -1.0, -50.0], [50.0, 0.0, 50.0]),
|
||||
])
|
||||
}
|
||||
|
||||
/// Brush-Naht mit kleinem Höhensprung: obere Fläche (Oberkante y=0.3, x≤0)
|
||||
/// trifft auf untere (Oberkante y=0, x≥0). Sprung 0.3 < SNAP_DIST.
|
||||
fn step_down() -> CollisionWorld {
|
||||
collision::from_brushes(vec![
|
||||
collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.3, 50.0]),
|
||||
collision::aabb_brush([0.0, -1.0, -50.0], [50.0, 0.0, 50.0]),
|
||||
])
|
||||
}
|
||||
|
||||
/// Wie `step_down`, aber der Absatz ist 2.0 tief — größer als SNAP_DIST,
|
||||
/// also ein echter Abgrund.
|
||||
fn tall_ledge() -> CollisionWorld {
|
||||
collision::from_brushes(vec![
|
||||
collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.0, 50.0]),
|
||||
collision::aabb_brush([0.0, -3.0, -50.0], [50.0, -2.0, 50.0]),
|
||||
])
|
||||
}
|
||||
|
||||
/// Solides Dreiecksprisma (Slope steigt von (x=0,y=0) auf (x=2,y=1)) plus
|
||||
/// untere Fläche (Oberkante y=0, x≤0), die am Slope-Fuß bündig anschließt.
|
||||
fn ramp() -> CollisionWorld {
|
||||
let s = 5.0f32.sqrt();
|
||||
collision::from_brushes(vec![
|
||||
vec![
|
||||
collision::Plane { n: [-1.0 / s, 2.0 / s, 0.0], d: 0.0 }, // Slope-Oberfläche
|
||||
collision::Plane { n: [0.0, -1.0, 0.0], d: 0.0 }, // Unterseite (y≥0)
|
||||
collision::Plane { n: [1.0, 0.0, 0.0], d: 2.0 }, // x≤2
|
||||
collision::Plane { n: [-1.0, 0.0, 0.0], d: 0.0 }, // x≥0 (Slope-Fuß)
|
||||
collision::Plane { n: [0.0, 0.0, 1.0], d: 50.0 },
|
||||
collision::Plane { n: [0.0, 0.0, -1.0], d: 50.0 },
|
||||
],
|
||||
collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.0, 50.0]),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_under_gravity() {
|
||||
let mut p = Player::new([0.0, 5.0, 0.0]);
|
||||
p.step(&void(), 0.0, 0.0, false, 0.1);
|
||||
assert!(p.pos[1] < 5.0, "sollte fallen: y={}", p.pos[1]);
|
||||
assert!(p.vel[1] < 0.0, "vel.y sollte negativ sein: {}", p.vel[1]);
|
||||
assert!(!p.grounded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lands_and_rests_on_ground() {
|
||||
let w = floor();
|
||||
let mut p = Player::new([0.0, 0.5, 0.0]);
|
||||
p.vel[1] = -10.0;
|
||||
// Mehrere Schritte fallen + setzen lassen.
|
||||
for _ in 0..5 { p.step(&w, 0.0, 0.0, false, 0.1); }
|
||||
assert!(p.grounded, "sollte auf dem Boden stehen");
|
||||
assert!(p.pos[1] >= 0.0 && p.pos[1] < 0.05, "Füße ~ auf y=0: {}", p.pos[1]);
|
||||
assert!(p.vel[1].abs() < 1e-3, "vertikal in Ruhe: {}", p.vel[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_only_when_grounded() {
|
||||
// Am Boden: Sprung hebt ab.
|
||||
let mut p = Player::new([0.0, 0.0, 0.0]);
|
||||
p.grounded = true;
|
||||
p.step(&void(), 0.0, 0.0, true, 0.016);
|
||||
assert!(p.vel[1] > 0.0, "Sprung sollte vel.y heben: {}", p.vel[1]);
|
||||
assert!(!p.grounded);
|
||||
|
||||
// In der Luft: kein zweiter Sprung — vel.y bleibt von Gravitation
|
||||
// bestimmt (negativ), nicht auf JUMP_SPEED gesetzt.
|
||||
let mut a = Player::new([0.0, 5.0, 0.0]);
|
||||
a.grounded = false;
|
||||
a.step(&void(), 0.0, 0.0, true, 0.016);
|
||||
assert!(a.vel[1] < 0.0, "kein Doppelsprung: {}", a.vel[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_horizontally_along_yaw() {
|
||||
// yaw=0: vorwärts → -Z, strafe → +X; y bleibt am Boden (kein Drift).
|
||||
let w = floor();
|
||||
let mut p = Player::new([0.0, 0.0, 0.0]);
|
||||
p.grounded = true;
|
||||
p.step(&w, 1.0, 0.0, false, 0.1);
|
||||
assert!(p.pos[2] < 0.0, "vorwärts sollte -Z sein: {}", p.pos[2]);
|
||||
assert!(p.pos[0].abs() < 1e-6, "kein Seitversatz: {}", p.pos[0]);
|
||||
assert!(p.pos[1] < 0.05, "kein Y-Drift: {}", p.pos[1]);
|
||||
|
||||
let mut q = Player::new([0.0, 0.0, 0.0]);
|
||||
q.grounded = true;
|
||||
q.step(&w, 0.0, 1.0, false, 0.1);
|
||||
assert!(q.pos[0] > 0.0, "strafe rechts sollte +X sein: {}", q.pos[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looking_up_does_not_lift_movement() {
|
||||
// Pitch nach oben darf die Horizontalbewegung nicht kippen.
|
||||
let mut p = Player::new([0.0, 0.0, 0.0]);
|
||||
p.grounded = true;
|
||||
p.pitch = 1.0;
|
||||
p.step(&floor(), 1.0, 0.0, false, 0.1);
|
||||
assert!(p.pos[1] < 0.05, "Hochschauen darf nicht abheben: y={}", p.pos[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaw_quarter_turn_walks_minus_x() {
|
||||
// yaw=90° blickt -X (wie math::view) → vorwärts bewegt nach -X.
|
||||
let mut p = Player::new([0.0, 0.0, 0.0]);
|
||||
p.grounded = true;
|
||||
p.yaw = std::f32::consts::FRAC_PI_2;
|
||||
p.step(&floor(), 1.0, 0.0, false, 0.1);
|
||||
assert!(p.pos[0] < 0.0, "yaw=90° vorwärts sollte -X sein: {}", p.pos[0]);
|
||||
assert!(p.pos[2].abs() < 1e-6, "kein Z-Anteil: {}", p.pos[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wall_blocks_horizontal_movement() {
|
||||
// Wand bei x=1 (Brush x∈[1,3]); Player läuft nach +X (yaw=-90°) dagegen.
|
||||
let w = collision::from_brushes(vec![
|
||||
collision::aabb_brush([1.0, -1.0, -50.0], [3.0, 5.0, 50.0]),
|
||||
]);
|
||||
let mut p = Player::new([0.0, 0.0, 0.0]);
|
||||
p.grounded = true;
|
||||
p.yaw = -std::f32::consts::FRAC_PI_2; // vorwärts → +X
|
||||
for _ in 0..10 { p.step(&w, 1.0, 0.0, false, 0.05); }
|
||||
// AABB-Halbbreite 0.3 → Füße stoppen vor x = 1 - 0.3 = 0.7.
|
||||
assert!(p.pos[0] < 0.72, "sollte an der Wand stoppen: x={}", p.pos[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ground_snap_keeps_grounded_over_step_down() {
|
||||
let w = step_down();
|
||||
let mut p = Player::new([-1.0, 0.6, 0.0]);
|
||||
for _ in 0..6 { p.step(&w, 0.0, 0.0, false, 0.1); } // auf obere Fläche fallen
|
||||
assert!(p.grounded && (p.pos[1] - 0.3).abs() < 0.05, "auf oberer Fläche: y={}", p.pos[1]);
|
||||
|
||||
p.yaw = -std::f32::consts::FRAC_PI_2; // vorwärts → +X über die Naht
|
||||
for _ in 0..8 {
|
||||
p.step(&w, 1.0, 0.0, false, 0.1);
|
||||
assert!(p.grounded, "Snap soll über die Stufe am Boden halten: y={}", p.pos[1]);
|
||||
}
|
||||
assert!(p.pos[0] > 0.3, "sollte die Naht überquert haben: x={}", p.pos[0]);
|
||||
assert!(p.pos[1] < 0.05, "auf der unteren Fläche angekommen: y={}", p.pos[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ground_snap_follows_downhill_slope() {
|
||||
let w = ramp();
|
||||
let mut p = Player::new([1.7, 1.3, 0.0]);
|
||||
for _ in 0..8 { p.step(&w, 0.0, 0.0, false, 0.1); } // auf die Slope setzen
|
||||
assert!(p.grounded, "auf der Slope gelandet: y={}", p.pos[1]);
|
||||
|
||||
p.yaw = std::f32::consts::FRAC_PI_2; // vorwärts → -X (abwärts)
|
||||
let y0 = p.pos[1];
|
||||
for _ in 0..3 {
|
||||
p.step(&w, 1.0, 0.0, false, 0.1);
|
||||
assert!(p.grounded, "Snap soll auf der Slope am Boden halten: y={}", p.pos[1]);
|
||||
}
|
||||
assert!(p.pos[1] < y0 - 0.1, "sollte die Slope hinab (y sinkt): {y0} -> {}", p.pos[1]);
|
||||
assert!(p.pos[0] < 1.7, "bewegt sich abwärts (-X): x={}", p.pos[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_snap_off_tall_ledge() {
|
||||
let w = tall_ledge();
|
||||
let mut p = Player::new([-1.0, 0.3, 0.0]);
|
||||
for _ in 0..5 { p.step(&w, 0.0, 0.0, false, 0.1); } // auf obere Fläche setzen
|
||||
p.yaw = -std::f32::consts::FRAC_PI_2; // über die hohe Kante nach +X
|
||||
for _ in 0..6 { p.step(&w, 1.0, 0.0, false, 0.1); }
|
||||
assert!(!p.grounded, "über die hohe Kante (> SNAP_DIST) soll er fallen");
|
||||
assert!(p.pos[1] < 0.0, "fällt ins Tiefere: y={}", p.pos[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jump_overrides_snap() {
|
||||
let w = floor();
|
||||
let mut p = Player::new([0.0, 0.5, 0.0]); // von oben einpendeln (ds>0)
|
||||
for _ in 0..5 { p.step(&w, 0.0, 0.0, false, 0.1); } // am Boden setzen
|
||||
assert!(p.grounded);
|
||||
p.step(&w, 0.0, 0.0, true, 0.1); // springen
|
||||
assert!(!p.grounded, "Sprung darf nicht vom Snap annulliert werden");
|
||||
assert!(p.vel[1] > 0.0, "steigt nach dem Sprung: {}", p.vel[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_up_over_curb() {
|
||||
// 0.4 hohe Kante (< STEP_HEIGHT) vor einer Fläche: muss erstiegen werden.
|
||||
let w = collision::from_brushes(vec![
|
||||
collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.0, 50.0]), // Boden y=0, x≤0
|
||||
collision::aabb_brush([0.0, -1.0, -50.0], [50.0, 0.4, 50.0]), // Stufe y=0.4, x≥0
|
||||
]);
|
||||
let mut p = Player::new([-1.0, 0.3, 0.0]);
|
||||
for _ in 0..30 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); }
|
||||
p.yaw = -std::f32::consts::FRAC_PI_2; // vorwärts → +X gegen die Stufe
|
||||
for _ in 0..120 { p.step(&w, 1.0, 0.0, false, 1.0 / 60.0); }
|
||||
assert!(p.pos[0] > 0.3, "sollte die Stufe erstiegen haben: x={}", p.pos[0]);
|
||||
assert!((p.pos[1] - 0.4).abs() < 0.05, "steht auf der Stufe: y={}", p.pos[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_step_blocks() {
|
||||
// 1.0 hohe Wand (> STEP_HEIGHT): kein Erklimmen.
|
||||
let w = collision::from_brushes(vec![
|
||||
collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 0.0, 50.0]),
|
||||
collision::aabb_brush([0.0, -1.0, -50.0], [50.0, 1.0, 50.0]),
|
||||
]);
|
||||
let mut p = Player::new([-1.0, 0.3, 0.0]);
|
||||
for _ in 0..30 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); }
|
||||
p.yaw = -std::f32::consts::FRAC_PI_2;
|
||||
for _ in 0..120 { p.step(&w, 1.0, 0.0, false, 1.0 / 60.0); }
|
||||
assert!(p.pos[0] < -0.25, "hohe Wand muss blocken: x={}", p.pos[0]);
|
||||
}
|
||||
|
||||
/// Slope (von (0,1) hinab auf (2,0)) zwischen oberer Fläche (y=1, x≤0) und
|
||||
/// unterer (y=0, x≥2). Genau der reale Naht-Fall — bei 60 fps getestet.
|
||||
fn slope_between_floors() -> CollisionWorld {
|
||||
let s = 5.0f32.sqrt();
|
||||
collision::from_brushes(vec![
|
||||
collision::aabb_brush([-50.0, -1.0, -50.0], [0.0, 1.0, 50.0]),
|
||||
vec![
|
||||
collision::Plane { n: [1.0 / s, 2.0 / s, 0.0], d: 2.0 / s }, // Slope-Oberfläche
|
||||
collision::Plane { n: [0.0, -1.0, 0.0], d: 0.0 },
|
||||
collision::Plane { n: [-1.0, 0.0, 0.0], d: 0.0 },
|
||||
collision::Plane { n: [1.0, 0.0, 0.0], d: 2.0 },
|
||||
collision::Plane { n: [0.0, 0.0, 1.0], d: 50.0 },
|
||||
collision::Plane { n: [0.0, 0.0, -1.0], d: 50.0 },
|
||||
],
|
||||
collision::aabb_brush([2.0, -1.0, -50.0], [50.0, 0.0, 50.0]),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_down_slope_across_seams_at_60fps() {
|
||||
let w = slope_between_floors();
|
||||
let mut p = Player::new([-1.0, 1.3, 0.0]);
|
||||
for _ in 0..60 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); } // obere Fläche
|
||||
assert!(p.grounded && (p.pos[1] - 1.0).abs() < 0.05, "auf oberer Fläche: y={}", p.pos[1]);
|
||||
|
||||
p.yaw = -std::f32::consts::FRAC_PI_2; // +X, abwärts über beide Nähte
|
||||
for i in 0..180 {
|
||||
p.step(&w, 1.0, 0.0, false, 1.0 / 60.0);
|
||||
assert!(p.grounded, "darf an der Naht nicht hängenbleiben (i={i}, x={}, y={})", p.pos[0], p.pos[1]);
|
||||
}
|
||||
assert!(p.pos[0] > 2.0, "sollte auf die untere Fläche gelangt sein: x={}", p.pos[0]);
|
||||
assert!((p.pos[1]).abs() < 0.05, "auf der unteren Fläche: y={}", p.pos[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walks_up_slope_across_seams_at_60fps() {
|
||||
let w = slope_between_floors();
|
||||
let mut p = Player::new([4.0, 0.3, 0.0]);
|
||||
for _ in 0..60 { p.step(&w, 0.0, 0.0, false, 1.0 / 60.0); } // untere Fläche
|
||||
assert!(p.grounded && p.pos[1].abs() < 0.05, "auf unterer Fläche: y={}", p.pos[1]);
|
||||
|
||||
p.yaw = std::f32::consts::FRAC_PI_2; // -X, aufwärts über beide Nähte
|
||||
for i in 0..180 {
|
||||
p.step(&w, 1.0, 0.0, false, 1.0 / 60.0);
|
||||
assert!(p.grounded, "darf an der Naht nicht hängenbleiben (i={i}, x={}, y={})", p.pos[0], p.pos[1]);
|
||||
}
|
||||
assert!(p.pos[0] < 0.0, "sollte auf die obere Fläche gelangt sein: x={}", p.pos[0]);
|
||||
assert!((p.pos[1] - 1.0).abs() < 0.05, "auf der oberen Fläche: y={}", p.pos[1]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! Signal → Action Dispatcher.
|
||||
//!
|
||||
//! Triggers (Objekt-Interaktion, Zone-Enter, Ink-Tag, Konsole) feuern
|
||||
//! *Signals* als Strings. Eine zentrale Table in `assets/signals.toml` mappt
|
||||
//! jedes Signal auf eine Liste von Actions. Actions sind selbst wieder
|
||||
//! Strings im Format `"verb args"` — dieselbe Vokabular-Familie wie
|
||||
//! Ink-Tags, sodass beide auf dieselbe Execute-Pipeline laufen.
|
||||
//!
|
||||
//! Builtin-Verben:
|
||||
//! `start_ink <script>` Story aus `assets/interactions/` in den
|
||||
//! Story-Slot laden; das Frontend treibt sie
|
||||
//! dann über `story_ctrl::advance`.
|
||||
//! `debug_log <text>` eprintln!
|
||||
//! `set <name> <value>` KV setzen (true/false/i32/f32/string)
|
||||
//! `inc <name> [<delta>]` KV-Integer inkrementieren (Default +1)
|
||||
//! `clear <name>` KV-Eintrag entfernen
|
||||
//! `hide_object <name>` → Action::HideObject (deferred)
|
||||
//! `play_sound <file>` → Action::PlaySound (deferred)
|
||||
//! `mode <play|free|menu>` → Action::SetMode (deferred): Anzeige-/
|
||||
//! Eingabemodus wechseln
|
||||
//!
|
||||
//! Parameter-Substitution: vor dem Parsen ersetzt `execute` `$self` in den
|
||||
//! Action-Args durch `ctx.instance_name`. Damit kann eine generische Action
|
||||
//! wie `hide_object $self` für viele duplizierte Instances wirken, ohne pro
|
||||
//! Instance einen Eintrag in signals.toml zu brauchen.
|
||||
//!
|
||||
//! Dispatch-Reihenfolge: erst Signal-Lookup; steht der Name in der Table,
|
||||
//! laufen alle gemappten Actions. Sonst wird der String direkt als
|
||||
//! Builtin-Aufruf probiert — so wirken Ink-Tags wie `# set has_key true`
|
||||
//! ohne Umweg über signals.toml. Unbekannte Verben werden still ignoriert.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::read_to_string;
|
||||
|
||||
use crate::engine::game::{Action, ActionCtx, ModeTarget, Signals};
|
||||
use crate::engine::kv;
|
||||
use crate::engine::story_ctrl;
|
||||
|
||||
/// Strippt Blenders Duplikat-Suffix (`.NNN` mit reinen Ziffern am Ende),
|
||||
/// damit `Mushroom.001`, `Mushroom.042` etc. alle auf `[Mushroom]` in
|
||||
/// signals.toml mappen. Andere Punkte im Namen bleiben unberührt, solange
|
||||
/// der letzte Token nicht rein numerisch ist.
|
||||
pub fn signal_key(name: &str) -> &str {
|
||||
if let Some(dot) = name.rfind('.') {
|
||||
let tail = &name[dot + 1..];
|
||||
if !tail.is_empty() && tail.chars().all(|c| c.is_ascii_digit()) {
|
||||
return &name[..dot];
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
pub fn dispatch(signal: &str, ctx: &mut ActionCtx) {
|
||||
// Signal-Name in der Table? Dann alle gemappten Actions ausführen.
|
||||
if let Some(actions) = ctx.signals.get(signal) {
|
||||
let actions = actions.clone(); // entkoppelt vom ctx.signals-Borrow
|
||||
for a in &actions { execute(a, ctx); }
|
||||
return;
|
||||
}
|
||||
// Sonst direkt als Builtin probieren (Ink-Tag-Pfad).
|
||||
execute(signal, ctx);
|
||||
}
|
||||
|
||||
fn execute(cmd: &str, ctx: &mut ActionCtx) {
|
||||
// $self → ctx.instance_name. Substituieren bevor wir Verb/Args splitten,
|
||||
// damit Tokens wie `hide_object $self` einheitlich funktionieren. Ohne
|
||||
// instance_name (z.B. `init`-Signal) bleibt `$self` stehen — die Action
|
||||
// zielt dann ins Leere, was für deferred Actions ein No-Op beim
|
||||
// Konsumenten ist.
|
||||
let cmd_owned: String;
|
||||
let cmd_ref: &str = if cmd.contains("$self") {
|
||||
if let Some(name) = ctx.instance_name.as_deref() {
|
||||
cmd_owned = cmd.replace("$self", name);
|
||||
&cmd_owned
|
||||
} else { cmd }
|
||||
} else { cmd };
|
||||
let (verb, args) = cmd_ref.split_once(char::is_whitespace).unwrap_or((cmd_ref, ""));
|
||||
let args = args.trim();
|
||||
match verb {
|
||||
"start_ink" => story_ctrl::start(args, ctx),
|
||||
"debug_log" => eprintln!("[signal] {args}"),
|
||||
"set" => kv::apply_set(args, ctx.kv),
|
||||
"inc" => kv::apply_inc(args, ctx.kv),
|
||||
"clear" => kv::apply_clear(args, ctx.kv),
|
||||
"hide_object" => ctx.actions.push(Action::HideObject(args.to_string())),
|
||||
"play_sound" => ctx.actions.push(Action::PlaySound(args.to_string())),
|
||||
"mode" => if let Some(t) = ModeTarget::parse(args) {
|
||||
ctx.actions.push(Action::SetMode(t));
|
||||
},
|
||||
_ => { /* Unbekannter Verb/Tag → still ignorieren */ }
|
||||
}
|
||||
}
|
||||
|
||||
/// Parst das signals.toml-Subset: `[signal]`-Header gefolgt von einem
|
||||
/// `actions = [ "…", … ]`-Array. Bewusst handgerollt statt toml-Crate —
|
||||
/// das Format ist trivial und die Engine bleibt dependency-arm.
|
||||
pub fn parse_signals(src: &str) -> Signals {
|
||||
let mut map: Signals = HashMap::new();
|
||||
let mut cur = String::new();
|
||||
let mut in_arr = false;
|
||||
for line in src.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') { continue; }
|
||||
if !in_arr && line.starts_with('[') {
|
||||
cur = line.trim_matches(|c| c == '[' || c == ']').to_string();
|
||||
map.entry(cur.clone()).or_default();
|
||||
continue;
|
||||
}
|
||||
if !in_arr && line.starts_with("actions") && line.contains('[') {
|
||||
in_arr = true;
|
||||
continue;
|
||||
}
|
||||
if in_arr {
|
||||
if line.starts_with(']') { in_arr = false; continue; }
|
||||
let s = line.trim_end_matches(',').trim().trim_matches('"');
|
||||
if s.is_empty() { continue; }
|
||||
map.entry(cur.clone()).or_default().push(s.to_string());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Lädt `signals.toml`. Fehlende Datei → leere Table (kein Fehler), damit
|
||||
/// ein Projekt ohne Signals lauffähig bleibt.
|
||||
pub fn load_signals(path: &str) -> Signals {
|
||||
match read_to_string(path) {
|
||||
Ok(src) => parse_signals(&src),
|
||||
Err(_) => Signals::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::engine::game::Game;
|
||||
|
||||
#[test]
|
||||
fn signal_key_strips_blender_suffix() {
|
||||
assert_eq!(signal_key("Mushroom.005"), "Mushroom");
|
||||
assert_eq!(signal_key("Mushroom"), "Mushroom");
|
||||
assert_eq!(signal_key("my.thing.with.dots"), "my.thing.with.dots");
|
||||
assert_eq!(signal_key("name."), "name.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_signals_table() {
|
||||
let src = r#"
|
||||
# Kommentar
|
||||
[init]
|
||||
actions = [
|
||||
"set foo 1",
|
||||
"debug_log hi",
|
||||
]
|
||||
|
||||
[Empty]
|
||||
actions = [
|
||||
]
|
||||
"#;
|
||||
let s = parse_signals(src);
|
||||
assert_eq!(s["init"], vec!["set foo 1", "debug_log hi"]);
|
||||
assert!(s["Empty"].is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_runs_actions_and_queues_deferred() {
|
||||
let mut signals = Signals::new();
|
||||
signals.insert("pickup".into(), vec![
|
||||
"inc items".into(),
|
||||
"play_sound pickup.wav".into(),
|
||||
"hide_object $self".into(),
|
||||
]);
|
||||
let mut game = Game::new(signals);
|
||||
|
||||
let mut ctx = game.action_ctx(Some("Mushroom.005".into()));
|
||||
dispatch("pickup", &mut ctx);
|
||||
|
||||
assert_eq!(game.kv["items"].coerce_to_int().unwrap(), 1);
|
||||
assert_eq!(game.actions, vec![
|
||||
Action::PlaySound("pickup.wav".into()),
|
||||
Action::HideObject("Mushroom.005".into()),
|
||||
]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_verb_queues_setmode_action() {
|
||||
let mut game = Game::new(Signals::new());
|
||||
let mut ctx = game.action_ctx(None);
|
||||
dispatch("mode free", &mut ctx);
|
||||
assert_eq!(game.actions, vec![Action::SetMode(ModeTarget::Free)]);
|
||||
|
||||
// Unbekanntes Ziel → kein Action (stilles No-Op).
|
||||
game.actions.clear();
|
||||
let mut ctx = game.action_ctx(None);
|
||||
dispatch("mode wat", &mut ctx);
|
||||
assert!(game.actions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_signal_falls_through_to_builtin() {
|
||||
let mut game = Game::new(Signals::new());
|
||||
let mut ctx = game.action_ctx(None);
|
||||
// Ink-Tag-Pfad: kein Table-Eintrag, direkt als Verb ausführen.
|
||||
dispatch("set has_key true", &mut ctx);
|
||||
assert!(game.kv["has_key"].coerce_to_bool().unwrap());
|
||||
|
||||
// Unbekanntes Verb ist ein stilles No-Op.
|
||||
let mut ctx = game.action_ctx(None);
|
||||
dispatch("color story_a", &mut ctx);
|
||||
assert!(game.actions.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
//! Story-Lifecycle: Ink-Skript in den Story-Slot laden und schrittweise
|
||||
//! treiben. Frontend-agnostisch — anders als in irl3d, wo `StoryState`
|
||||
//! direkt in UI-Panels gegossen wurde, konsumiert hier der Aufrufer
|
||||
//! (CLI-REPL jetzt, Panel-UI später) den Zustand selbst.
|
||||
//!
|
||||
//! Tags eines Schritts werden als `Vec<String>` zurückgegeben — der
|
||||
//! Aufrufer entscheidet, ob/wie er sie via `signals::dispatch` weiterreicht
|
||||
//! (vermeidet den Modul-Zyklus signals → story_ctrl → signals).
|
||||
|
||||
use crate::engine::assets;
|
||||
use crate::engine::game::ActionCtx;
|
||||
use crate::engine::ink::{Story, StoryState};
|
||||
|
||||
/// Lädt ein Ink-Skript (Pfad relativ zu `assets/interactions/`) in den
|
||||
/// Story-Slot. Der erste Schritt passiert noch nicht — das Frontend ruft
|
||||
/// dafür `advance(None, …)`.
|
||||
pub fn start(script: &str, ctx: &mut ActionCtx) {
|
||||
let path = assets::path(&format!("assets/interactions/{script}"));
|
||||
*ctx.story = Some(Story::load(&path, ctx.kv));
|
||||
}
|
||||
|
||||
/// Treibt die laufende Story einen Schritt weiter. `sel` ist die
|
||||
/// Choice-Auswahl bzw. `None` beim ersten Schritt oder Text-Weiterklicken.
|
||||
/// Bei `End` wird der Story-Slot geleert. Rückgabe: neuer Zustand (oder
|
||||
/// `None` wenn gar keine Story läuft) plus Tags zum Dispatchen.
|
||||
pub fn advance(sel: Option<usize>, ctx: &mut ActionCtx) -> (Option<StoryState>, Vec<String>) {
|
||||
let Some(st) = ctx.story.as_mut() else { return (None, Vec::new()); };
|
||||
if let Some(i) = sel { st.choose(i); }
|
||||
let state = st.cont(ctx.kv);
|
||||
let tags = st.take_tags();
|
||||
if matches!(state, StoryState::End) { *ctx.story = None; }
|
||||
(Some(state), tags)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! TGA-Decoder → fertig dekodiertes RGBA8-`Image`.
|
||||
//!
|
||||
//! Neutral: kennt weder GPU noch UI noch Fonts. Liefert *immer* RGBA8,
|
||||
//! Ursprung oben-links, vollständig konvertiert — der Aufrufer macht keine
|
||||
//! Nachbearbeitung. (Anders als irl3d, wo der Loader BGR zurückgab und die
|
||||
//! RGB-Umrechnung jedem Konsumenten einzeln überließ.) Konsumenten ziehen
|
||||
//! ein `Image` und legen es ab, wie sie es brauchen: Renderer → GPU-Textur,
|
||||
//! UI/Font → eigene Interpretation. Die UV-/V-Achsen-Konvention ist damit
|
||||
//! Sache des Konsumenten beim Sampling, nicht des Loaders.
|
||||
//!
|
||||
//! Unterstützt True-Color unkomprimiert (Typ 2) und RLE (Typ 10) mit 24
|
||||
//! oder 32 bpp, sowie Graustufen (Typ 3/11, 8 bpp → zu RGBA expandiert) für
|
||||
//! Font-Maps. Paletten-TGAs gibt es nicht mehr (wds ist 15-bit True-Color).
|
||||
|
||||
pub struct Image {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// `width * height * 4`, Ursprung oben-links.
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn load(path: &str) -> Image {
|
||||
let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("tga load {path}: {e}"));
|
||||
decode(&bytes).unwrap_or_else(|e| panic!("tga decode {path}: {e}"))
|
||||
}
|
||||
|
||||
pub fn decode(d: &[u8]) -> Result<Image, String> {
|
||||
if d.len() < 18 { return Err("Header zu kurz".into()); }
|
||||
let id_len = d[0] as usize;
|
||||
let cmap_type = d[1];
|
||||
let image_type = d[2];
|
||||
let width = u16::from_le_bytes([d[12], d[13]]) as u32;
|
||||
let height = u16::from_le_bytes([d[14], d[15]]) as u32;
|
||||
let depth = d[16];
|
||||
let descriptor = d[17];
|
||||
|
||||
if cmap_type != 0 {
|
||||
return Err("Color-Map-TGAs nicht unterstützt (True-Color only)".into());
|
||||
}
|
||||
// Pixeldaten beginnen nach Header + ID-Feld (Color-Map ist leer).
|
||||
let off = 18 + id_len;
|
||||
|
||||
let channels = match (image_type, depth) {
|
||||
(2 | 10, 24) => 3,
|
||||
(2 | 10, 32) => 4,
|
||||
(3 | 11, 8) => 1,
|
||||
(t, b) => return Err(format!("nicht unterstützt: Typ {t}, {b} bpp")),
|
||||
};
|
||||
let n = (width as usize) * (height as usize);
|
||||
if n == 0 { return Err("Nullgröße".into()); }
|
||||
|
||||
// Rohpixel (channels Bytes je Pixel), bei RLE entpackt.
|
||||
let raw_len = n * channels;
|
||||
let raw: Vec<u8> = match image_type {
|
||||
2 | 3 => d.get(off..off + raw_len).ok_or("Pixeldaten zu kurz")?.to_vec(),
|
||||
10 | 11 => decode_rle(d.get(off..).ok_or("RLE-Daten fehlen")?, raw_len, channels)?,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
|
||||
// → RGBA8. True-Color liegt im TGA als BGR(A) vor; Graustufen replizieren.
|
||||
let mut rgba = vec![0u8; n * 4];
|
||||
for i in 0..n {
|
||||
let (r, g, b, a) = match channels {
|
||||
1 => { let v = raw[i]; (v, v, v, 255) }
|
||||
3 => (raw[i * 3 + 2], raw[i * 3 + 1], raw[i * 3], 255),
|
||||
4 => (raw[i * 4 + 2], raw[i * 4 + 1], raw[i * 4], raw[i * 4 + 3]),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
let o = i * 4;
|
||||
rgba[o] = r; rgba[o + 1] = g; rgba[o + 2] = b; rgba[o + 3] = a;
|
||||
}
|
||||
|
||||
// Ursprung oben-links erzwingen. Descriptor-Bit 5 gesetzt = top-origin.
|
||||
if (descriptor >> 5) & 1 == 0 {
|
||||
flip_vertical(&mut rgba, width as usize, height as usize);
|
||||
}
|
||||
Ok(Image { width, height, rgba })
|
||||
}
|
||||
|
||||
/// TGA-RLE: Pakete operieren auf ganzen Pixeln (`ch` Bytes).
|
||||
fn decode_rle(src: &[u8], raw_len: usize, ch: usize) -> Result<Vec<u8>, String> {
|
||||
let mut out = Vec::with_capacity(raw_len);
|
||||
let mut si = 0;
|
||||
while out.len() < raw_len {
|
||||
let header = *src.get(si).ok_or("RLE: Quelle zu kurz")?;
|
||||
si += 1;
|
||||
let count = (header & 0x7f) as usize + 1;
|
||||
if header & 0x80 != 0 {
|
||||
// RLE-Paket: ein Pixel count-mal.
|
||||
let px = src.get(si..si + ch).ok_or("RLE: Pixel zu kurz")?;
|
||||
si += ch;
|
||||
for _ in 0..count { out.extend_from_slice(px); }
|
||||
} else {
|
||||
// Raw-Paket: count Pixel am Stück.
|
||||
let bytes = count * ch;
|
||||
let chunk = src.get(si..si + bytes).ok_or("RLE: Chunk zu kurz")?;
|
||||
si += bytes;
|
||||
out.extend_from_slice(chunk);
|
||||
}
|
||||
}
|
||||
out.truncate(raw_len);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn flip_vertical(rgba: &mut [u8], w: usize, h: usize) {
|
||||
let row = w * 4;
|
||||
for y in 0..h / 2 {
|
||||
let (a, b) = (y * row, (h - 1 - y) * row);
|
||||
for x in 0..row { rgba.swap(a + x, b + x); }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bgr_to_rgba_uncompressed() {
|
||||
// Minimal-TGA: Typ 2, 2×1, 24 bpp, bottom-origin (descriptor 0).
|
||||
// Zwei Pixel BGR (10,20,30) und (40,50,60).
|
||||
let mut d = vec![0u8; 18];
|
||||
d[2] = 2; d[12] = 2; d[14] = 1; d[16] = 24;
|
||||
d.extend_from_slice(&[10, 20, 30, 40, 50, 60]);
|
||||
let img = decode(&d).unwrap();
|
||||
assert_eq!((img.width, img.height), (2, 1));
|
||||
assert_eq!(&img.rgba[0..4], &[30, 20, 10, 255]);
|
||||
assert_eq!(&img.rgba[4..8], &[60, 50, 40, 255]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rle_truecolor_expands() {
|
||||
// Typ 10, 4×1, 24 bpp: ein RLE-Paket (4× BGR 1,2,3).
|
||||
let mut d = vec![0u8; 18];
|
||||
d[2] = 10; d[12] = 4; d[14] = 1; d[16] = 24;
|
||||
d.push(0x80 | 3); // RLE, count = 4
|
||||
d.extend_from_slice(&[1, 2, 3]); // ein Pixel
|
||||
let img = decode(&d).unwrap();
|
||||
assert_eq!(img.rgba.len(), 4 * 4);
|
||||
assert!(img.rgba.chunks(4).all(|p| p == [3, 2, 1, 255]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_real_placeholder() {
|
||||
let bytes = std::fs::read("assets/textures/placeholder.tga").unwrap();
|
||||
let img = decode(&bytes).unwrap();
|
||||
assert_eq!((img.width, img.height), (256, 256));
|
||||
assert_eq!(img.rgba.len(), 256 * 256 * 4);
|
||||
assert!(img.rgba.chunks(4).all(|p| p[3] == 255)); // 24bpp → Alpha 255
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,25 @@
|
||||
//! wds — Weirdcore Dating Simulator.
|
||||
//!
|
||||
//! Aufbau: `engine/` ist der headless Kern (Signal/Action-Dispatcher,
|
||||
//! KV-Store, Ink-Stories); `session` hält den geteilten Anwendungszustand
|
||||
//! darüber (Game + Dialog-Modus + Befehls-Interpreter). Beide Frontends
|
||||
//! steuern dieselbe Session: das Fenster (`render`, Default) und die
|
||||
//! Konsolen-REPL (`cli`, via `--cli`).
|
||||
|
||||
mod cli;
|
||||
mod engine;
|
||||
mod render;
|
||||
mod session;
|
||||
|
||||
use engine::assets;
|
||||
use session::Session;
|
||||
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
let signals_path = assets::path("assets/signals.toml");
|
||||
let session = Session::new(signals_path.clone());
|
||||
if std::env::args().any(|a| a == "--cli") {
|
||||
cli::run(session, &signals_path);
|
||||
} else {
|
||||
render::run(session);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Upscale-Pass: internes 320×240-Target nearest-gesampelt auf die
|
||||
// Surface. Das 4:3-Letterbox-Rechteck setzt der Rust-Code als Viewport;
|
||||
// hier ist es ein simples Fullscreen-Dreieck mit UVs.
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4f,
|
||||
@location(0) uv: vec2f,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(@builtin(vertex_index) i: u32) -> VsOut {
|
||||
var pos = array<vec2f, 3>(
|
||||
vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0),
|
||||
);
|
||||
var out: VsOut;
|
||||
out.pos = vec4f(pos[i], 0.0, 1.0);
|
||||
// NDC (y hoch) → UV (v runter).
|
||||
out.uv = vec2f(pos[i].x * 0.5 + 0.5, 0.5 - pos[i].y * 0.5);
|
||||
return out;
|
||||
}
|
||||
|
||||
@group(0) @binding(0) var internal_tex: texture_2d<f32>;
|
||||
@group(0) @binding(1) var internal_smp: sampler;
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4f {
|
||||
return textureSample(internal_tex, internal_smp, in.uv);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
//! Brush → Render-Geometrie.
|
||||
//!
|
||||
//! Ein Quake-Brush ist der Schnitt der Halbräume seiner Face-Ebenen. Aus
|
||||
//! den Ebenen rekonstruieren wir das konvexe Polyeder (Standard-Verfahren:
|
||||
//! alle Ebenen-Tripel schneiden, nur Punkte behalten, die in *allen*
|
||||
//! Halbräumen liegen), bilden pro Face das Polygon und fächern es zu
|
||||
//! Dreiecken. Indizes werden nach Textur gruppiert (ein Draw-Batch je
|
||||
//! Textur).
|
||||
//!
|
||||
//! Koplanar-Elimination: stoßen zwei Brushes aneinander, teilen sie eine
|
||||
//! Fläche — beide Faces liegen auf derselben Ebene, mit entgegengesetzten
|
||||
//! Normalen, und sind nie sichtbar. Wir verwerfen jede Face, die von einer
|
||||
//! solchen gegenüberliegenden Face vollständig überdeckt wird (hinter ihr
|
||||
//! liegt dann garantiert solides Brush-Inneres). Zusammen mit dem Back-face
|
||||
//! Culling der Pipeline bleibt so nur die Außenhaut übrig — ohne BSP.
|
||||
//!
|
||||
//! UVs nach dem Standard-Map-Format: die Textur-Achsen ergeben sich aus der
|
||||
//! dominanten Flächennormale (Quake-`baseaxis`-Tabelle), dann Rotation,
|
||||
//! Skalierung und Offset der Face. Das erzeugt das klassische Quake-
|
||||
//! Verziehen auf schrägen Flächen — gewollt fürs PS1-Gefühl.
|
||||
//!
|
||||
//! Achsen: Quake ist Z-up, unsere Engine Y-up mit Blick -Z. UVs werden im
|
||||
//! Quake-System gerechnet (so sind Offsets/Scale definiert), die Position
|
||||
//! danach umgerechnet: `(qx, qy, qz) → (qx, qz, -qy)`, mal `MAP_SCALE`.
|
||||
|
||||
use crate::engine::map::{self, Face, Map};
|
||||
use crate::render::scene::{Batch, Mesh, Vertex};
|
||||
|
||||
/// Punkt liegt „auf" einer Ebene / „innerhalb" eines Halbraums (Quake-Units).
|
||||
const ON_EPS: f32 = 1e-2;
|
||||
|
||||
struct Plane {
|
||||
n: [f32; 3], // nach außen, im Quake-System
|
||||
d: f32, // n · x = d
|
||||
}
|
||||
|
||||
/// Ein fertig rekonstruiertes Face-Polygon (Quake-Koords, CCW um `n`),
|
||||
/// vor der UV-/Engine-Umrechnung. Hält die Ebene für die Koplanar-Prüfung
|
||||
/// und einen Verweis auf die Quell-Face (Textur, Offsets, Skalierung).
|
||||
struct FacePoly<'a> {
|
||||
n: [f32; 3],
|
||||
d: f32,
|
||||
poly: Vec<[f32; 3]>,
|
||||
face: &'a Face,
|
||||
}
|
||||
|
||||
/// Distinkte Texturnamen in der Map (sortiert) — die Indizes hier sind die
|
||||
/// Textur-Indizes, auf die `Batch::texture` und der Bilder-Vec verweisen.
|
||||
pub fn texture_names(map: &Map) -> Vec<String> {
|
||||
let mut names: Vec<String> = map.entities.iter()
|
||||
.flat_map(|e| &e.brushes)
|
||||
.flat_map(|b| &b.faces)
|
||||
.map(|f| f.texture.clone())
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
/// Alle Brushes zu einem Mesh. `tex_names` (aus [`texture_names`]) gibt die
|
||||
/// Textur-Indizes, `dims` die Pixelmaße je Textur (für UV-Normalisierung).
|
||||
pub fn build(map: &Map, tex_names: &[String], dims: &[(u32, u32)]) -> Mesh {
|
||||
// 1) Alle Face-Polygone rekonstruieren (noch in Quake-Koords).
|
||||
let mut faces: Vec<FacePoly> = Vec::new();
|
||||
for ent in &map.entities {
|
||||
for brush in &ent.brushes {
|
||||
collect_faces(&brush.faces, &mut faces);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Koplanar verdeckte Faces markieren.
|
||||
let hidden = mark_hidden(&faces);
|
||||
|
||||
// 3) Überlebende mit UV in den Vertex-Buffer ausgeben, nach Textur
|
||||
// gruppiert.
|
||||
let mut verts = Vec::new();
|
||||
let mut per_tex: Vec<Vec<u32>> = vec![Vec::new(); tex_names.len()];
|
||||
for (i, fp) in faces.iter().enumerate() {
|
||||
if hidden[i] { continue; }
|
||||
emit_face(fp, tex_names, dims, &mut verts, &mut per_tex);
|
||||
}
|
||||
|
||||
// Pro-Textur-Indexlisten zu einem Buffer + Batches verflachen.
|
||||
let mut indices = Vec::new();
|
||||
let mut batches = Vec::new();
|
||||
for (ti, list) in per_tex.into_iter().enumerate() {
|
||||
if list.is_empty() { continue; }
|
||||
batches.push(Batch { texture: ti, start: indices.len() as u32, count: list.len() as u32 });
|
||||
indices.extend_from_slice(&list);
|
||||
}
|
||||
Mesh { verts, indices, batches }
|
||||
}
|
||||
|
||||
/// Die Face-Polygone *eines* Brushes rekonstruieren und an `out` anhängen.
|
||||
fn collect_faces<'a>(faces: &'a [Face], out: &mut Vec<FacePoly<'a>>) {
|
||||
let planes: Vec<Plane> = faces.iter().map(|f| plane(&f.plane)).collect();
|
||||
let n = planes.len();
|
||||
|
||||
// Gültige Eckpunkte: Schnitt je dreier Ebenen, sofern in allen
|
||||
// Halbräumen.
|
||||
let mut corners: Vec<[f32; 3]> = Vec::new();
|
||||
for i in 0..n {
|
||||
for j in (i + 1)..n {
|
||||
for k in (j + 1)..n {
|
||||
let Some(p) = intersect(&planes[i], &planes[j], &planes[k]) else { continue; };
|
||||
if planes.iter().all(|pl| dot(pl.n, p) <= pl.d + ON_EPS)
|
||||
&& !corners.iter().any(|q| dist2(*q, p) < ON_EPS * ON_EPS)
|
||||
{
|
||||
corners.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pro Face: Eckpunkte auf der Ebene sammeln, CCW um die Normale sortieren.
|
||||
for (fi, face) in faces.iter().enumerate() {
|
||||
let pl = &planes[fi];
|
||||
let mut poly: Vec<[f32; 3]> = corners.iter().copied()
|
||||
.filter(|&p| (dot(pl.n, p) - pl.d).abs() < ON_EPS)
|
||||
.collect();
|
||||
if poly.len() < 3 { continue; }
|
||||
|
||||
let center = average(&poly);
|
||||
let axis_u = any_perp(pl.n);
|
||||
let axis_v = cross(pl.n, axis_u);
|
||||
poly.sort_by(|a, b| {
|
||||
angle(*a, center, axis_u, axis_v)
|
||||
.partial_cmp(&angle(*b, center, axis_u, axis_v))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
out.push(FacePoly { n: pl.n, d: pl.d, poly, face });
|
||||
}
|
||||
}
|
||||
|
||||
/// Markiert Faces, die von einer gegenüberliegenden, deckungsgleichen Face
|
||||
/// (anderer Brush) vollständig überdeckt werden — also nie sichtbar sind.
|
||||
fn mark_hidden(faces: &[FacePoly]) -> Vec<bool> {
|
||||
let mut hidden = vec![false; faces.len()];
|
||||
for i in 0..faces.len() {
|
||||
let a = &faces[i];
|
||||
for (j, b) in faces.iter().enumerate() {
|
||||
if i == j { continue; }
|
||||
// Gegenüberliegende, koplanare Ebene? (n_a ≈ -n_b und d_a ≈ -d_b,
|
||||
// denn n_b·x = d_b ⇒ n_a·x = -d_b muss a.d treffen.)
|
||||
if dot(a.n, b.n) > -0.999 { continue; }
|
||||
if (a.d + b.d).abs() > ON_EPS { continue; }
|
||||
if covered_by(&a.poly, &b.poly, a.n) {
|
||||
hidden[i] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
hidden
|
||||
}
|
||||
|
||||
/// Liegt jeder Eckpunkt von `inner` innerhalb des Polygons `outer`? Beide
|
||||
/// liegen (per Aufruf) auf derselben Ebene mit Normale `n`; wir projizieren
|
||||
/// in deren 2D-Basis. Testpunkte werden minimal zur Polygonmitte gezogen,
|
||||
/// damit deckungsgleiche Ränder/Ecken eindeutig als „innen" zählen.
|
||||
fn covered_by(inner: &[[f32; 3]], outer: &[[f32; 3]], n: [f32; 3]) -> bool {
|
||||
let u = any_perp(n);
|
||||
let v = cross(n, u);
|
||||
let c = average(inner);
|
||||
let proj = |p: [f32; 3]| -> [f32; 2] { let d = sub(p, c); [dot(d, u), dot(d, v)] };
|
||||
let outer2: Vec<[f32; 2]> = outer.iter().map(|&p| proj(p)).collect();
|
||||
inner.iter().all(|&p| {
|
||||
let pp = [
|
||||
p[0] + 1e-3 * (c[0] - p[0]),
|
||||
p[1] + 1e-3 * (c[1] - p[1]),
|
||||
p[2] + 1e-3 * (c[2] - p[2]),
|
||||
];
|
||||
in_poly_2d(proj(pp), &outer2)
|
||||
})
|
||||
}
|
||||
|
||||
/// Crossing-Number-Punkt-in-Polygon in 2D (orientierungsunabhängig).
|
||||
fn in_poly_2d(p: [f32; 2], poly: &[[f32; 2]]) -> bool {
|
||||
let mut inside = false;
|
||||
let mut j = poly.len() - 1;
|
||||
for i in 0..poly.len() {
|
||||
let (a, b) = (poly[i], poly[j]);
|
||||
if (a[1] > p[1]) != (b[1] > p[1]) {
|
||||
let t = (p[1] - a[1]) / (b[1] - a[1]);
|
||||
if p[0] < a[0] + t * (b[0] - a[0]) { inside = !inside; }
|
||||
}
|
||||
j = i;
|
||||
}
|
||||
inside
|
||||
}
|
||||
|
||||
/// Ein Face-Polygon mit UV in den Vertex-Buffer fächern.
|
||||
fn emit_face(
|
||||
fp: &FacePoly,
|
||||
tex_names: &[String],
|
||||
dims: &[(u32, u32)],
|
||||
verts: &mut Vec<Vertex>,
|
||||
per_tex: &mut [Vec<u32>],
|
||||
) {
|
||||
let Some(ti) = tex_names.iter().position(|nm| nm == &fp.face.texture) else { return; };
|
||||
let (tw, th) = dims[ti];
|
||||
let (su, sv) = tex_axes(fp.n, fp.face); // skalierte Textur-Achsen (Quake)
|
||||
let base = verts.len() as u32;
|
||||
for p in &fp.poly {
|
||||
// UV in Texeln, dann auf 0..1 normalisiert.
|
||||
let u = (dot(*p, su) + fp.face.offset[0]) / tw as f32;
|
||||
let v = (dot(*p, sv) + fp.face.offset[1]) / th as f32;
|
||||
verts.push(Vertex { pos: map::to_engine(*p), uv: [u, v] });
|
||||
}
|
||||
for t in 1..(fp.poly.len() as u32 - 1) {
|
||||
per_tex[ti].extend([base, base + t, base + t + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ebene aus drei Face-Punkten. Quake listet sie im Uhrzeigersinn von vorn,
|
||||
/// damit `cross(c-a, b-a)` nach außen zeigt.
|
||||
fn plane(p: &[[f32; 3]; 3]) -> Plane {
|
||||
let n = normalize(cross(sub(p[2], p[0]), sub(p[1], p[0])));
|
||||
Plane { n, d: dot(n, p[0]) }
|
||||
}
|
||||
|
||||
/// Schnittpunkt dreier Ebenen (Cramer); `None`, wenn sie ~parallel sind.
|
||||
fn intersect(a: &Plane, b: &Plane, c: &Plane) -> Option<[f32; 3]> {
|
||||
let denom = dot(a.n, cross(b.n, c.n));
|
||||
if denom.abs() < 1e-6 { return None; }
|
||||
let mut x = scale(cross(b.n, c.n), a.d);
|
||||
x = add(x, scale(cross(c.n, a.n), b.d));
|
||||
x = add(x, scale(cross(a.n, b.n), c.d));
|
||||
Some(scale(x, 1.0 / denom))
|
||||
}
|
||||
|
||||
/// Textur-Achsen (skaliert) im Quake-System nach dem Standard-Map-Format:
|
||||
/// dominante Achse aus der Normale, dann Rotation und Skalierung.
|
||||
fn tex_axes(n: [f32; 3], face: &Face) -> ([f32; 3], [f32; 3]) {
|
||||
let (mut u, mut v) = base_axes(n);
|
||||
|
||||
let (sinv, cosv) = face.rotation.to_radians().sin_cos();
|
||||
// Basis-Achsen sind achsen-ausgerichtet (genau eine Komponente ≠ 0);
|
||||
// wir rotieren ihre beiden in-plane-Komponenten.
|
||||
let su = nonzero_axis(u);
|
||||
let sv = nonzero_axis(v);
|
||||
for vec in [&mut u, &mut v] {
|
||||
let a = cosv * vec[su] - sinv * vec[sv];
|
||||
let b = sinv * vec[su] + cosv * vec[sv];
|
||||
vec[su] = a;
|
||||
vec[sv] = b;
|
||||
}
|
||||
|
||||
let sx = if face.scale[0] == 0.0 { 1.0 } else { face.scale[0] };
|
||||
let sy = if face.scale[1] == 0.0 { 1.0 } else { face.scale[1] };
|
||||
(scale(u, 1.0 / sx), scale(v, 1.0 / sy))
|
||||
}
|
||||
|
||||
/// Quake-`baseaxis`-Tabelle: (Normalen-Kandidat, U-Achse, V-Achse) × 6.
|
||||
/// Die U/V-Achsen der am besten passenden Normale bilden die Textur-Ebene.
|
||||
fn base_axes(n: [f32; 3]) -> ([f32; 3], [f32; 3]) {
|
||||
const BA: [[[f32; 3]; 3]; 6] = [
|
||||
[[0.0, 0.0, 1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]], // Boden (+Z)
|
||||
[[0.0, 0.0, -1.0], [1.0, 0.0, 0.0], [0.0, -1.0, 0.0]], // Decke (-Z)
|
||||
[[ 1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], // West (+X)
|
||||
[[-1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, -1.0]], // Ost (-X)
|
||||
[[0.0, 1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]], // Süd (+Y)
|
||||
[[0.0, -1.0, 0.0], [1.0, 0.0, 0.0], [0.0, 0.0, -1.0]], // Nord (-Y)
|
||||
];
|
||||
let mut best = -1.0;
|
||||
let mut bi = 0;
|
||||
for (i, ax) in BA.iter().enumerate() {
|
||||
let d = dot(n, ax[0]);
|
||||
if d > best { best = d; bi = i; }
|
||||
}
|
||||
(BA[bi][1], BA[bi][2])
|
||||
}
|
||||
|
||||
// --- kleine Vektor-Helfer auf [f32; 3] ---------------------------------------
|
||||
|
||||
fn sub(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] - b[0], a[1] - b[1], a[2] - b[2]] }
|
||||
fn add(a: [f32; 3], b: [f32; 3]) -> [f32; 3] { [a[0] + b[0], a[1] + b[1], a[2] + b[2]] }
|
||||
fn scale(a: [f32; 3], s: f32) -> [f32; 3] { [a[0] * s, a[1] * s, a[2] * s] }
|
||||
fn dot(a: [f32; 3], b: [f32; 3]) -> f32 { a[0] * b[0] + a[1] * b[1] + a[2] * b[2] }
|
||||
fn dist2(a: [f32; 3], b: [f32; 3]) -> f32 { let d = sub(a, b); dot(d, d) }
|
||||
|
||||
fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
|
||||
[a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
|
||||
}
|
||||
|
||||
fn normalize(a: [f32; 3]) -> [f32; 3] {
|
||||
let len = dot(a, a).sqrt();
|
||||
if len > 0.0 { scale(a, 1.0 / len) } else { a }
|
||||
}
|
||||
|
||||
fn average(pts: &[[f32; 3]]) -> [f32; 3] {
|
||||
let mut c = [0.0; 3];
|
||||
for p in pts { c = add(c, *p); }
|
||||
scale(c, 1.0 / pts.len() as f32)
|
||||
}
|
||||
|
||||
/// Index der (einzigen) Nicht-Null-Komponente einer achsen-ausgerichteten Achse.
|
||||
fn nonzero_axis(a: [f32; 3]) -> usize {
|
||||
if a[0] != 0.0 { 0 } else if a[1] != 0.0 { 1 } else { 2 }
|
||||
}
|
||||
|
||||
/// Achse in der Ebene, möglichst weit weg von der Normale, als Sortier-Basis.
|
||||
fn any_perp(n: [f32; 3]) -> [f32; 3] {
|
||||
let a = if n[0].abs() < 0.9 { [1.0, 0.0, 0.0] } else { [0.0, 1.0, 0.0] };
|
||||
normalize(cross(n, a))
|
||||
}
|
||||
|
||||
/// Winkel von `p` um `center` in der (u, v)-Basis — für CCW-Sortierung.
|
||||
fn angle(p: [f32; 3], center: [f32; 3], u: [f32; 3], v: [f32; 3]) -> f32 {
|
||||
let d = sub(p, center);
|
||||
dot(d, v).atan2(dot(d, u))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::engine::map;
|
||||
|
||||
// Ein achsenparalleler Quader-Brush (x[-48,80], y[-64,64], z[-48,-16]).
|
||||
const BOX: &str = r#"
|
||||
{
|
||||
"classname" "worldspawn"
|
||||
{
|
||||
( -48 -64 -48 ) ( -48 -63 -48 ) ( -48 -64 -47 ) t 0 0 0 1 1
|
||||
( -48 -64 -48 ) ( -48 -64 -47 ) ( -47 -64 -48 ) t 0 0 0 1 1
|
||||
( -48 -64 -48 ) ( -47 -64 -48 ) ( -48 -63 -48 ) t 0 0 0 1 1
|
||||
( 80 64 -16 ) ( 80 65 -16 ) ( 81 64 -16 ) t 0 0 0 1 1
|
||||
( 80 64 -16 ) ( 81 64 -16 ) ( 80 64 -15 ) t 0 0 0 1 1
|
||||
( 80 64 -16 ) ( 80 64 -15 ) ( 80 65 -16 ) t 0 0 0 1 1
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn box_brush_has_cuboid_geometry() {
|
||||
let m = map::parse(BOX);
|
||||
let names = texture_names(&m);
|
||||
assert_eq!(names, vec!["t".to_string()]);
|
||||
let mesh = build(&m, &names, &[(64, 64)]);
|
||||
|
||||
// Quader: 6 Faces · 4 Ecken = 24 Vertices, 6 · 2 Dreiecke · 3 = 36 Indizes.
|
||||
assert_eq!(mesh.verts.len(), 24);
|
||||
assert_eq!(mesh.indices.len(), 36);
|
||||
// Eine Textur → ein Batch über alle Indizes.
|
||||
assert_eq!(mesh.batches.len(), 1);
|
||||
assert_eq!((mesh.batches[0].texture, mesh.batches[0].start, mesh.batches[0].count), (0, 0, 36));
|
||||
|
||||
// Engine-Bounds: x[-1.5,2.5], y(=quake z)[-1.5,-0.5], z(=-quake y)[-2,2].
|
||||
let (mut mn, mut mx) = ([f32::MAX; 3], [f32::MIN; 3]);
|
||||
for v in &mesh.verts {
|
||||
for k in 0..3 {
|
||||
mn[k] = mn[k].min(v.pos[k]);
|
||||
mx[k] = mx[k].max(v.pos[k]);
|
||||
}
|
||||
}
|
||||
let approx = |a: f32, b: f32| (a - b).abs() < 1e-4;
|
||||
assert!(approx(mn[0], -1.5) && approx(mx[0], 2.5), "x: {:?}..{:?}", mn[0], mx[0]);
|
||||
assert!(approx(mn[1], -1.5) && approx(mx[1], -0.5), "y: {:?}..{:?}", mn[1], mx[1]);
|
||||
assert!(approx(mn[2], -2.0) && approx(mx[2], 2.0), "z: {:?}..{:?}", mn[2], mx[2]);
|
||||
}
|
||||
|
||||
// Zwei achsenparallele Würfel, die sich die Ebene x=64 teilen:
|
||||
// A = x[0,64], B = x[64,128], beide y[0,64] z[0,64].
|
||||
const TWO_BOXES: &str = r#"
|
||||
{
|
||||
"classname" "worldspawn"
|
||||
{
|
||||
( 0 0 0 ) ( 0 1 0 ) ( 0 0 1 ) t 0 0 0 1 1
|
||||
( 0 0 0 ) ( 0 0 1 ) ( 1 0 0 ) t 0 0 0 1 1
|
||||
( 0 0 0 ) ( 1 0 0 ) ( 0 1 0 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 64 65 64 ) ( 65 64 64 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 65 64 64 ) ( 64 64 65 ) t 0 0 0 1 1
|
||||
( 64 64 64 ) ( 64 64 65 ) ( 64 65 64 ) t 0 0 0 1 1
|
||||
}
|
||||
{
|
||||
( 64 0 0 ) ( 64 1 0 ) ( 64 0 1 ) t 0 0 0 1 1
|
||||
( 64 0 0 ) ( 64 0 1 ) ( 65 0 0 ) t 0 0 0 1 1
|
||||
( 64 0 0 ) ( 65 0 0 ) ( 64 1 0 ) t 0 0 0 1 1
|
||||
( 128 64 64 ) ( 128 65 64 ) ( 129 64 64 ) t 0 0 0 1 1
|
||||
( 128 64 64 ) ( 129 64 64 ) ( 128 64 65 ) t 0 0 0 1 1
|
||||
( 128 64 64 ) ( 128 64 65 ) ( 128 65 64 ) t 0 0 0 1 1
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn shared_faces_are_eliminated() {
|
||||
let m = map::parse(TWO_BOXES);
|
||||
let names = texture_names(&m);
|
||||
let mesh = build(&m, &names, &[(64, 64)]);
|
||||
|
||||
// 2 Würfel · 6 Faces = 12, minus die 2 deckungsgleichen an x=64 → 10.
|
||||
// 10 Faces · 4 Ecken = 40 Vertices, 10 · 2 Dreiecke · 3 = 60 Indizes.
|
||||
// (Ohne Elimination wären es 48 Vertices / 72 Indizes.)
|
||||
assert_eq!(mesh.verts.len(), 40);
|
||||
assert_eq!(mesh.indices.len(), 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uvs_scale_with_texture_size() {
|
||||
// Auf der +Z-Boden-Face (baseaxis U=+X, V=-Y) ist U = qx/tw, V = -qy/th
|
||||
// bei Offset 0, Scale 1. Prüft, dass UVs in Texeln/Größe gerechnet werden.
|
||||
let m = map::parse(BOX);
|
||||
let names = texture_names(&m);
|
||||
let mesh = build(&m, &names, &[(64, 64)]);
|
||||
// Alle UVs endlich und im erwarteten groben Bereich (Brush ~128 units
|
||||
// breit / 64 Texel → bis ~2).
|
||||
assert!(mesh.verts.iter().all(|v| v.uv[0].is_finite() && v.uv[1].is_finite()));
|
||||
assert!(mesh.verts.iter().any(|v| v.uv[0].abs() > 0.5));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Flycam: freie Debug-/Entwicklungskamera (Schritt 4). Position, Yaw und
|
||||
//! Pitch in f32 — Port der irl3d-Kamera-Idee, aber ohne Q16.16: an dieser
|
||||
//! Kamera hängt keine Physik, und der Renderer rechnet ohnehin f32.
|
||||
//!
|
||||
//! Konvention wie `math::Mat4::view`: yaw=0 blickt -Z, positiver Yaw dreht
|
||||
//! nach links, positiver Pitch hebt den Blick.
|
||||
|
||||
use crate::render::math::Mat4;
|
||||
|
||||
/// Knapp unter 90°: hält den Blick aus der Senkrechten, wo Yaw bedeutungslos
|
||||
/// würde (und `right`/`forward` zusammenfielen).
|
||||
const PITCH_LIMIT: f32 = 1.55;
|
||||
|
||||
pub struct Camera {
|
||||
pub pos: [f32; 3],
|
||||
pub yaw: f32,
|
||||
pub pitch: f32,
|
||||
}
|
||||
|
||||
impl Camera {
|
||||
pub fn new(pos: [f32; 3]) -> Self {
|
||||
Self { pos, yaw: 0.0, pitch: 0.0 }
|
||||
}
|
||||
|
||||
/// Roh-Maus-Delta (Pixel) → Yaw/Pitch. `sens` in Radiant pro Pixel.
|
||||
/// Maus nach rechts dreht den Blick nach rechts (Yaw sinkt), Maus nach
|
||||
/// unten senkt ihn; Pitch wird geclamped.
|
||||
pub fn apply_mouse(&mut self, dx: f32, dy: f32, sens: f32) {
|
||||
self.yaw -= dx * sens;
|
||||
self.pitch = (self.pitch - dy * sens).clamp(-PITCH_LIMIT, PITCH_LIMIT);
|
||||
}
|
||||
|
||||
/// Blickrichtung inkl. Pitch — W/S bewegen sich hier entlang.
|
||||
fn forward(&self) -> [f32; 3] {
|
||||
let (sy, cy) = self.yaw.sin_cos();
|
||||
let (sp, cp) = self.pitch.sin_cos();
|
||||
[-sy * cp, sp, -cy * cp]
|
||||
}
|
||||
|
||||
/// Horizontales Rechts (ohne Pitch) — A/D strafen hier entlang, ohne
|
||||
/// dass der Blick nach oben/unten die Bewegung kippt.
|
||||
fn right(&self) -> [f32; 3] {
|
||||
let (sy, cy) = self.yaw.sin_cos();
|
||||
[cy, 0.0, -sy]
|
||||
}
|
||||
|
||||
/// Bewegung integrieren. `fwd`/`right`/`up` je in [-1, 1] (Tastenachsen),
|
||||
/// `dist` ist die Strecke diesen Frame (Speed · dt). Hoch/runter ist
|
||||
/// Welt-Y, damit Auf-/Absteigen unabhängig vom Blickwinkel bleibt.
|
||||
pub fn translate(&mut self, fwd: f32, right: f32, up: f32, dist: f32) {
|
||||
let f = self.forward();
|
||||
let r = self.right();
|
||||
for i in 0..3 {
|
||||
self.pos[i] += (f[i] * fwd + r[i] * right) * dist;
|
||||
}
|
||||
self.pos[1] += up * dist;
|
||||
}
|
||||
|
||||
pub fn view(&self) -> Mat4 {
|
||||
Mat4::view(self.pos, self.yaw, self.pitch)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Die Bewegungs-Basis (`forward`) muss exakt zu der Richtung passen,
|
||||
/// die `view()` als „vor der Kamera" definiert — sonst driften
|
||||
/// Steuerung und Bild auseinander. Ein Punkt `d` vor der Kamera muss
|
||||
/// in View-Space auf (0, 0, -d) landen.
|
||||
#[test]
|
||||
fn forward_matches_view() {
|
||||
for &(yaw, pitch) in &[(0.0, 0.0), (0.7, 0.0), (0.0, 0.5), (1.2, -0.4)] {
|
||||
let c = Camera { pos: [1.0, 2.0, 3.0], yaw, pitch };
|
||||
let f = c.forward();
|
||||
let ahead = [
|
||||
c.pos[0] + f[0] * 5.0,
|
||||
c.pos[1] + f[1] * 5.0,
|
||||
c.pos[2] + f[2] * 5.0,
|
||||
1.0,
|
||||
];
|
||||
let v = c.view().transform(ahead);
|
||||
assert!(
|
||||
(v[0]).abs() < 1e-4 && (v[1]).abs() < 1e-4 && (v[2] + 5.0).abs() < 1e-4,
|
||||
"yaw={yaw} pitch={pitch}: {v:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `right` muss horizontal sein und im View-Space nach +X zeigen
|
||||
/// (sonst wären A/D vertauscht oder gekippt).
|
||||
#[test]
|
||||
fn right_is_horizontal_and_points_view_right() {
|
||||
let c = Camera { pos: [0.0, 0.0, 0.0], yaw: 0.6, pitch: 0.3 };
|
||||
let r = c.right();
|
||||
assert!(r[1].abs() < 1e-6, "right darf kein Y haben: {r:?}");
|
||||
let p = [r[0] * 5.0, r[1] * 5.0, r[2] * 5.0, 1.0];
|
||||
let v = c.view().transform(p);
|
||||
assert!(v[0] > 4.0, "right sollte nach View-+X zeigen: {v:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! CP437-Bitmap-Fonts mit proportionaler (variabler) Laufweite.
|
||||
//!
|
||||
//! Alle Atlanten sind nach demselben Schema aufgebaut: 32 Spalten × 8 Zeilen,
|
||||
//! row-major mit Codepoint = Index (`A` an 65, `a` an 97 → col = c%32,
|
||||
//! row = c/32). Die Glyphen liegen weiß auf schwarz *ohne* Alpha; wir leiten
|
||||
//! die Maske beim Laden aus der Luminanz ab (`ui::key_luminance`, geteilt mit
|
||||
//! Cursor- und Ornament-Sprites).
|
||||
//!
|
||||
//! Proportionale Breite: statt jeder Glyph-Zelle ihre volle Breite zu geben,
|
||||
//! **messen** wir beim Laden aus der Alpha-Maske die Tinten-Spannweite jedes
|
||||
//! Glyphs (linkeste/rechteste nicht-leere Spalte). Gezeichnet wird tight
|
||||
//! gecroppt, der Cursor rückt nur um Ink-Breite + Tracking vor; leere Glyphen
|
||||
//! (Space) bekommen eine feste Breite. So entsteht kein Sonderfall im Shader —
|
||||
//! nur die UV- und Advance-Rechnung wird pro Glyph variabel.
|
||||
//!
|
||||
//! Ein [`Font`] wird zur Laufzeit aus dem geladenen Atlas gebaut und kennt
|
||||
//! seinen GPU-Textur-Index, damit `ui::Ui::text` ohne Zusatzargumente damit
|
||||
//! zeichnen kann.
|
||||
|
||||
use crate::engine::tga::Image;
|
||||
|
||||
const COLS: u32 = 32;
|
||||
|
||||
/// Ein geladener Glyph-Atlas: Maße, gemessene Glyph-Breiten und der zugehörige
|
||||
/// Sprite-Textur-Index.
|
||||
pub(crate) struct Font {
|
||||
pub(crate) glyph_h: f32,
|
||||
glyph_w: f32,
|
||||
atlas_w: f32,
|
||||
atlas_h: f32,
|
||||
tex: usize,
|
||||
/// Ink-Startspalte je Codepoint (innerhalb der Zelle, 0..glyph_w).
|
||||
left: [u8; 256],
|
||||
/// Ink-Breite je Codepoint (0 = leer/Space).
|
||||
width: [u8; 256],
|
||||
tracking: f32,
|
||||
space_adv: f32,
|
||||
}
|
||||
|
||||
/// Die im Projekt geladenen Fonts, gebaut in render::run und an `ui::layout`
|
||||
/// gereicht.
|
||||
pub(crate) struct Fonts {
|
||||
/// Größerer Font, für spätere Überschriften/HUD bereitgehalten.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) ega: Font,
|
||||
pub(crate) cga: Font,
|
||||
}
|
||||
|
||||
impl Font {
|
||||
/// EGA 8×14 (`assets/textures/fonts/oldschool-ega-8x14.tga`, 256×112).
|
||||
pub(crate) fn ega(tex: usize, atlas: &Image) -> Font {
|
||||
Font::measure(8.0, 14.0, 256.0, 112.0, tex, atlas)
|
||||
}
|
||||
/// CGA 8×8 (`assets/textures/fonts/oldschool-cga-8x8.tga`, 256×64).
|
||||
pub(crate) fn cga(tex: usize, atlas: &Image) -> Font {
|
||||
Font::measure(8.0, 8.0, 256.0, 64.0, tex, atlas)
|
||||
}
|
||||
|
||||
/// Glyph-Breiten aus der Alpha-Maske des Atlas messen.
|
||||
fn measure(glyph_w: f32, glyph_h: f32, atlas_w: f32, atlas_h: f32, tex: usize, atlas: &Image) -> Font {
|
||||
let (gw, gh, aw) = (glyph_w as usize, glyph_h as usize, atlas_w as usize);
|
||||
let mut left = [0u8; 256];
|
||||
let mut width = [0u8; 256];
|
||||
for code in 0..256usize {
|
||||
let col = (code as u32 % COLS) as usize;
|
||||
let row = (code as u32 / COLS) as usize;
|
||||
let (l, w) = ink_bounds(&atlas.rgba, aw, col * gw, row * gh, gw, gh);
|
||||
left[code] = l;
|
||||
width[code] = w;
|
||||
}
|
||||
Font {
|
||||
glyph_h, glyph_w, atlas_w, atlas_h, tex, left, width,
|
||||
// 1px Abstand zwischen Glyphen; Space = halbe Zellbreite.
|
||||
tracking: 1.0,
|
||||
space_adv: (glyph_w * 0.5).round(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn tex(&self) -> usize { self.tex }
|
||||
|
||||
/// UV-Rechteck + Zeichenbreite (Pixel) eines Glyphs, tight gecroppt.
|
||||
/// `None` für leere Glyphen (Space) — dort wird nichts gezeichnet.
|
||||
pub(crate) fn glyph_quad(&self, code: u8) -> Option<([f32; 4], f32)> {
|
||||
let w = self.width[code as usize];
|
||||
if w == 0 { return None; }
|
||||
let col = (code as u32 % COLS) as f32;
|
||||
let row = (code as u32 / COLS) as f32;
|
||||
let x0 = col * self.glyph_w + self.left[code as usize] as f32;
|
||||
let y0 = row * self.glyph_h;
|
||||
let uv = [
|
||||
x0 / self.atlas_w, y0 / self.atlas_h,
|
||||
(x0 + w as f32) / self.atlas_w, (y0 + self.glyph_h) / self.atlas_h,
|
||||
];
|
||||
Some((uv, w as f32))
|
||||
}
|
||||
|
||||
/// Wie weit der Cursor nach diesem Glyph vorrückt.
|
||||
pub(crate) fn advance(&self, code: u8) -> f32 {
|
||||
match self.width[code as usize] {
|
||||
0 => self.space_adv,
|
||||
w => w as f32 + self.tracking,
|
||||
}
|
||||
}
|
||||
|
||||
/// Breite einer Zeichenkette in Pixeln (Summe der Advances).
|
||||
pub(crate) fn text_width(&self, s: &str) -> f32 {
|
||||
s.chars().map(|ch| self.advance(cp437(ch))).sum()
|
||||
}
|
||||
}
|
||||
|
||||
/// Linkeste Ink-Spalte und Ink-Breite einer Glyph-Zelle in der Alpha-Maske.
|
||||
/// `(0, 0)` wenn die Zelle leer ist.
|
||||
fn ink_bounds(rgba: &[u8], atlas_w: usize, x0: usize, y0: usize, gw: usize, gh: usize) -> (u8, u8) {
|
||||
let (mut lo, mut hi) = (gw, 0usize);
|
||||
let mut any = false;
|
||||
for y in 0..gh {
|
||||
for x in 0..gw {
|
||||
let a = rgba[((y0 + y) * atlas_w + (x0 + x)) * 4 + 3];
|
||||
if a > 0 {
|
||||
any = true;
|
||||
if x < lo { lo = x; }
|
||||
if x > hi { hi = x; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if any { (lo as u8, (hi - lo + 1) as u8) } else { (0, 0) }
|
||||
}
|
||||
|
||||
/// Ein Unicode-Zeichen auf seinen CP437-Codepoint abbilden. ASCII ist
|
||||
/// identisch; für die im Projekt nötigen deutschen Sonderzeichen gibt es
|
||||
/// feste Plätze, alles andere fällt auf '?' zurück.
|
||||
pub(crate) fn cp437(ch: char) -> u8 {
|
||||
match ch {
|
||||
c if (c as u32) < 128 => c as u8,
|
||||
'Ç' => 128, 'ü' => 129, 'é' => 130, 'ä' => 132, 'à' => 133,
|
||||
'å' => 134, 'ç' => 135, 'ë' => 137, 'è' => 138, 'ï' => 139,
|
||||
'î' => 140, 'ì' => 141, 'Ä' => 142, 'Å' => 143, 'É' => 144,
|
||||
'ö' => 148, 'ò' => 149, 'û' => 150, 'ù' => 151, 'Ö' => 153,
|
||||
'Ü' => 154, 'á' => 160, 'í' => 161, 'ó' => 162, 'ú' => 163,
|
||||
'ñ' => 164, 'Ñ' => 165, 'ß' => 225, '°' => 248,
|
||||
_ => b'?',
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ink_bounds_finds_span_and_blank() {
|
||||
// 4px-breite "Zelle" (atlas_w=4, 1 Zeile): Ink in Spalten 1..=2.
|
||||
// rgba: x0 leer, x1 voll, x2 voll, x3 leer (nur Alpha relevant).
|
||||
let rgba = vec![
|
||||
0,0,0,0, 255,255,255,255, 255,255,255,255, 0,0,0,0,
|
||||
];
|
||||
assert_eq!(ink_bounds(&rgba, 4, 0, 0, 4, 1), (1, 2));
|
||||
// Komplett leere Zelle.
|
||||
let blank = vec![0u8; 4 * 4];
|
||||
assert_eq!(ink_bounds(&blank, 4, 0, 0, 4, 1), (0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cp437_maps_ascii_and_umlauts() {
|
||||
assert_eq!(cp437('A'), 65);
|
||||
assert_eq!(cp437('a'), 97);
|
||||
assert_eq!(cp437('ä'), 132);
|
||||
assert_eq!(cp437('ß'), 225);
|
||||
assert_eq!(cp437('世'), b'?'); // außerhalb CP437 → Fallback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! wgpu-Zustand: Surface, Device und der zweistufige Render-Pfad
|
||||
//! aus dem Renderer-Plan:
|
||||
//!
|
||||
//! Pass 1 (intern): 320×240 RGBA8 + Depth — hier entsteht das Bild
|
||||
//! ([`ScenePass`], PS1-Shader).
|
||||
//! Pass 2 (Fenster): Nearest-Upscale des internen Targets mit
|
||||
//! 4:3-Letterbox (via Viewport) auf die Surface.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use winit::window::Window;
|
||||
|
||||
use crate::engine::tga::Image;
|
||||
use crate::render::math::Mat4;
|
||||
use crate::render::scene::{Mesh, ScenePass};
|
||||
use crate::render::sprite::SpritePass;
|
||||
use crate::render::ui::Ui;
|
||||
|
||||
pub const INTERNAL_W: u32 = 320;
|
||||
pub const INTERNAL_H: u32 = 240;
|
||||
|
||||
/// D16 reicht für PS1-Geometrieskalen und ist das älteste, überall
|
||||
/// (auch GL-Fallback) unterstützte Depth-Format.
|
||||
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth16Unorm;
|
||||
const INTERNAL_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
|
||||
|
||||
pub struct Gpu {
|
||||
surface: wgpu::Surface<'static>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
config: wgpu::SurfaceConfiguration,
|
||||
|
||||
internal_view: wgpu::TextureView,
|
||||
depth_view: wgpu::TextureView,
|
||||
|
||||
scene: ScenePass,
|
||||
sprite: SpritePass,
|
||||
blit_pipeline: wgpu::RenderPipeline,
|
||||
blit_bind: wgpu::BindGroup,
|
||||
}
|
||||
|
||||
impl Gpu {
|
||||
pub fn new(
|
||||
window: Arc<Window>,
|
||||
display: winit::event_loop::OwnedDisplayHandle,
|
||||
mesh: &Mesh,
|
||||
images: &[Image],
|
||||
ui_textures: &[Image],
|
||||
) -> Self {
|
||||
let size = window.inner_size();
|
||||
// Display-Handle mitgeben: für den GL-Fallback (v.a. Wayland)
|
||||
// Pflicht; Vulkan ignoriert es. `with_env` erlaubt Overrides wie
|
||||
// WGPU_BACKEND=gl zum Testen des Fallback-Pfads.
|
||||
let instance = wgpu::Instance::new(
|
||||
wgpu::InstanceDescriptor::new_with_display_handle(Box::new(display)).with_env(),
|
||||
);
|
||||
let surface = instance.create_surface(window).expect("wgpu: Surface");
|
||||
|
||||
let adapter = pollster::block_on(instance.request_adapter(
|
||||
&wgpu::RequestAdapterOptions {
|
||||
compatible_surface: Some(&surface),
|
||||
..Default::default()
|
||||
},
|
||||
)).expect("wgpu: kein Adapter");
|
||||
let info = adapter.get_info();
|
||||
println!("[gpu] {} ({:?}, {:?})", info.name, info.backend, info.device_type);
|
||||
|
||||
// Baseline-Limits/-Features: alles, was der Plan braucht, ist
|
||||
// WebGPU-Kern — nichts anfordern, dann läuft es auch auf HD 5500.
|
||||
let (device, queue) = pollster::block_on(
|
||||
adapter.request_device(&wgpu::DeviceDescriptor::default()),
|
||||
).expect("wgpu: Device");
|
||||
|
||||
// Nicht-sRGB-8-Bit-Surface bevorzugen: der Fragment-Shader
|
||||
// quantisiert später selbst auf RGB555 — die Werte sollen
|
||||
// unverändert auf den Schirm, ohne Gamma-Umkodierung beim Blit.
|
||||
// Explizite Liste statt „erstes nicht-sRGB": Treiber bieten auch
|
||||
// 16-Bit-Formate an, die extra Device-Features bräuchten.
|
||||
let caps = surface.get_capabilities(&adapter);
|
||||
let format = [wgpu::TextureFormat::Bgra8Unorm, wgpu::TextureFormat::Rgba8Unorm]
|
||||
.into_iter()
|
||||
.find(|f| caps.formats.contains(f))
|
||||
.unwrap_or(caps.formats[0]);
|
||||
println!("[gpu] Surface-Format {format:?}");
|
||||
let config = wgpu::SurfaceConfiguration {
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
format,
|
||||
width: size.width.max(1),
|
||||
height: size.height.max(1),
|
||||
present_mode: wgpu::PresentMode::AutoVsync,
|
||||
alpha_mode: caps.alpha_modes[0],
|
||||
view_formats: vec![],
|
||||
desired_maximum_frame_latency: 2,
|
||||
};
|
||||
surface.configure(&device, &config);
|
||||
|
||||
let (internal_view, depth_view) = make_internal_targets(&device);
|
||||
|
||||
// Pass 1: die Szene. Geometrie + Bilder kommen fertig vom Aufrufer
|
||||
// (Decode in render::run); hier wird nur hochgeladen.
|
||||
let scene = ScenePass::new(&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT, mesh, images);
|
||||
|
||||
// Overlay-Pass: zeichnet nach der Szene ins selbe interne Target.
|
||||
let sprite = SpritePass::new(
|
||||
&device, &queue, INTERNAL_FORMAT, DEPTH_FORMAT,
|
||||
[INTERNAL_W as f32, INTERNAL_H as f32], ui_textures,
|
||||
);
|
||||
|
||||
// Pass 2: internes Target nearest-gesampelt auf die Surface.
|
||||
let blit_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("blit"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("blit.wgsl").into()),
|
||||
});
|
||||
let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("blit"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &blit_shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &blit_shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(config.format.into())],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
depth_stencil: None,
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("blit nearest"),
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
let blit_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("blit"),
|
||||
layout: &blit_pipeline.get_bind_group_layout(0),
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: wgpu::BindingResource::TextureView(&internal_view),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: wgpu::BindingResource::Sampler(&sampler),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
Self {
|
||||
surface, device, queue, config,
|
||||
internal_view, depth_view,
|
||||
scene, sprite, blit_pipeline, blit_bind,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, width: u32, height: u32) {
|
||||
self.config.width = width.max(1);
|
||||
self.config.height = height.max(1);
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
}
|
||||
|
||||
/// Fenster-Pixel (physisch) → interne Pixel, über dieselbe Letterbox wie
|
||||
/// der Blit. Außerhalb des Bildbereichs auf die Ränder geklemmt.
|
||||
pub fn map_cursor(&self, win: [f32; 2]) -> [f32; 2] {
|
||||
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height);
|
||||
let ix = (win[0] - vx) / vw * INTERNAL_W as f32;
|
||||
let iy = (win[1] - vy) / vh * INTERNAL_H as f32;
|
||||
[ix.clamp(0.0, INTERNAL_W as f32), iy.clamp(0.0, INTERNAL_H as f32)]
|
||||
}
|
||||
|
||||
/// Einen Frame rendern. Die View-Matrix kommt vom Aufrufer (Flycam);
|
||||
/// die Projektion gehört hierher, weil sie an der internen Auflösung
|
||||
/// hängt.
|
||||
pub fn frame(&mut self, view: &Mat4, ui: &Ui) {
|
||||
let proj = Mat4::perspective(
|
||||
60f32.to_radians(),
|
||||
INTERNAL_W as f32 / INTERNAL_H as f32,
|
||||
0.1, 100.0,
|
||||
);
|
||||
let half_res = [INTERNAL_W as f32 * 0.5, INTERNAL_H as f32 * 0.5];
|
||||
self.scene.prepare(&self.queue, &proj.mul(view), half_res);
|
||||
self.sprite.prepare(&self.queue, &ui.verts);
|
||||
|
||||
use wgpu::CurrentSurfaceTexture as Cst;
|
||||
let frame = match self.surface.get_current_texture() {
|
||||
Cst::Success(f) | Cst::Suboptimal(f) => f,
|
||||
// Surface veraltet (Resize/Compositor): neu konfigurieren,
|
||||
// diesen Frame auslassen.
|
||||
Cst::Outdated | Cst::Lost => {
|
||||
self.surface.configure(&self.device, &self.config);
|
||||
return;
|
||||
}
|
||||
// Fenster verdeckt/minimiert oder Treiber-Timeout: auslassen.
|
||||
Cst::Timeout | Cst::Occluded => return,
|
||||
Cst::Validation => panic!("wgpu: Surface-Validation-Fehler"),
|
||||
};
|
||||
let surface_view = frame.texture.create_view(&Default::default());
|
||||
let mut enc = self.device.create_command_encoder(&Default::default());
|
||||
|
||||
// Pass 1: intern.
|
||||
{
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("internal"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &self.internal_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
|
||||
view: &self.depth_view,
|
||||
depth_ops: Some(wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(1.0),
|
||||
store: wgpu::StoreOp::Store,
|
||||
}),
|
||||
stencil_ops: None,
|
||||
}),
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
self.scene.draw(&mut pass);
|
||||
// Overlay obenauf, im selben Pass (kein Tiefentest).
|
||||
self.sprite.draw(&mut pass, &ui.batches);
|
||||
}
|
||||
|
||||
// Pass 2: Letterbox-Blit aufs Fenster.
|
||||
{
|
||||
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: Some("blit"),
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &surface_view,
|
||||
depth_slice: None,
|
||||
resolve_target: None,
|
||||
ops: wgpu::Operations {
|
||||
// Schwarz = die Letterbox-Balken.
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
depth_stencil_attachment: None,
|
||||
timestamp_writes: None,
|
||||
occlusion_query_set: None,
|
||||
multiview_mask: None,
|
||||
});
|
||||
let (vx, vy, vw, vh) = letterbox(self.config.width, self.config.height);
|
||||
pass.set_viewport(vx, vy, vw, vh, 0.0, 1.0);
|
||||
pass.set_pipeline(&self.blit_pipeline);
|
||||
pass.set_bind_group(0, &self.blit_bind, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
|
||||
self.queue.submit([enc.finish()]);
|
||||
frame.present();
|
||||
}
|
||||
}
|
||||
|
||||
fn make_internal_targets(device: &wgpu::Device) -> (wgpu::TextureView, wgpu::TextureView) {
|
||||
let size = wgpu::Extent3d {
|
||||
width: INTERNAL_W, height: INTERNAL_H, depth_or_array_layers: 1,
|
||||
};
|
||||
let color = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("internal color"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: INTERNAL_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
|
||||
view_formats: &[],
|
||||
});
|
||||
let depth = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("internal depth"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: DEPTH_FORMAT,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
|
||||
view_formats: &[],
|
||||
});
|
||||
(color.create_view(&Default::default()), depth.create_view(&Default::default()))
|
||||
}
|
||||
|
||||
/// Größtes 4:3-Rechteck, das ins Fenster passt, zentriert.
|
||||
/// Nicht-ganzzahlige Skalierung ist gewollt (volle Fensterausnutzung);
|
||||
/// die leicht ungleichen Pixel passen zum CRT-Vorbild.
|
||||
fn letterbox(win_w: u32, win_h: u32) -> (f32, f32, f32, f32) {
|
||||
let (w, h) = (win_w as f32, win_h as f32);
|
||||
let scale = (w / INTERNAL_W as f32).min(h / INTERNAL_H as f32);
|
||||
let vw = INTERNAL_W as f32 * scale;
|
||||
let vh = INTERNAL_H as f32 * scale;
|
||||
((w - vw) * 0.5, (h - vh) * 0.5, vw, vh)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! Minimale f32-Matrix-Mathematik für den Renderer — bewusst handgerollt
|
||||
//! statt glam: PS1-Style braucht nur Multiply, View und Perspective.
|
||||
//! (Die Spiellogik bekommt später ihr eigenes Q16.16 wie irl3d; das hier
|
||||
//! ist nur der GPU-Pfad.)
|
||||
//!
|
||||
//! Konventionen:
|
||||
//! - rechtshändig, Kamera blickt -Z, +Y oben (wie Blender-OBJ-Export)
|
||||
//! - Spaltenvektoren, Speicher column-major — `Mat4.0[spalte][zeile]`,
|
||||
//! bytemuck-kompatibel zu WGSL `mat4x4f`
|
||||
//! - Clip-Z in [0,1] (wgpu/WebGPU, nicht GL-[-1,1])
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub struct Mat4(pub [[f32; 4]; 4]);
|
||||
|
||||
impl Mat4 {
|
||||
pub const IDENT: Mat4 = Mat4([
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]);
|
||||
|
||||
pub fn mul(&self, rhs: &Mat4) -> Mat4 {
|
||||
let mut out = [[0.0f32; 4]; 4];
|
||||
for c in 0..4 {
|
||||
for r in 0..4 {
|
||||
out[c][r] = (0..4).map(|k| self.0[k][r] * rhs.0[c][k]).sum();
|
||||
}
|
||||
}
|
||||
Mat4(out)
|
||||
}
|
||||
|
||||
/// Punkt-Transformation auf der CPU — bisher nur von den Tests
|
||||
/// gebraucht; der Renderer transformiert auf der GPU.
|
||||
#[cfg(test)]
|
||||
pub fn transform(&self, v: [f32; 4]) -> [f32; 4] {
|
||||
let mut out = [0.0f32; 4];
|
||||
for r in 0..4 {
|
||||
out[r] = (0..4).map(|k| self.0[k][r] * v[k]).sum();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn translate(x: f32, y: f32, z: f32) -> Mat4 {
|
||||
let mut m = Mat4::IDENT;
|
||||
m.0[3] = [x, y, z, 1.0];
|
||||
m
|
||||
}
|
||||
|
||||
pub fn rot_x(a: f32) -> Mat4 {
|
||||
let (s, c) = a.sin_cos();
|
||||
let mut m = Mat4::IDENT;
|
||||
m.0[1] = [0.0, c, s, 0.0];
|
||||
m.0[2] = [0.0, -s, c, 0.0];
|
||||
m
|
||||
}
|
||||
|
||||
pub fn rot_y(a: f32) -> Mat4 {
|
||||
let (s, c) = a.sin_cos();
|
||||
let mut m = Mat4::IDENT;
|
||||
m.0[0] = [c, 0.0, -s, 0.0];
|
||||
m.0[2] = [s, 0.0, c, 0.0];
|
||||
m
|
||||
}
|
||||
|
||||
/// FPS-View: erst Kamera-Position abziehen, dann Yaw, dann Pitch
|
||||
/// herausdrehen. Yaw/Pitch wie die irl3d-Kamera: yaw=0 blickt -Z,
|
||||
/// positiver Pitch hebt den Blick.
|
||||
pub fn view(pos: [f32; 3], yaw: f32, pitch: f32) -> Mat4 {
|
||||
Mat4::rot_x(-pitch)
|
||||
.mul(&Mat4::rot_y(-yaw))
|
||||
.mul(&Mat4::translate(-pos[0], -pos[1], -pos[2]))
|
||||
}
|
||||
|
||||
/// Perspektive mit Clip-Z in [0,1]. `fovy` in Radiant.
|
||||
pub fn perspective(fovy: f32, aspect: f32, near: f32, far: f32) -> Mat4 {
|
||||
let f = 1.0 / (fovy * 0.5).tan();
|
||||
Mat4([
|
||||
[f / aspect, 0.0, 0.0, 0.0],
|
||||
[0.0, f, 0.0, 0.0],
|
||||
[0.0, 0.0, far / (near - far), -1.0],
|
||||
[0.0, 0.0, near * far / (near - far), 0.0],
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn close(a: [f32; 4], b: [f32; 4]) -> bool {
|
||||
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_translates_origin_in_front() {
|
||||
// Kamera bei z=+5, Blick -Z → Origin liegt 5 vor der Kamera.
|
||||
let v = Mat4::view([0.0, 0.0, 5.0], 0.0, 0.0);
|
||||
assert!(close(v.transform([0.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -5.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_yaw_quarter_turn_looks_minus_x() {
|
||||
// yaw=90°: Blick Richtung -X (irl3d-Konvention) — ein Punkt auf
|
||||
// -X liegt dann vor der Kamera.
|
||||
let v = Mat4::view([0.0, 0.0, 0.0], std::f32::consts::FRAC_PI_2, 0.0);
|
||||
assert!(close(v.transform([-2.0, 0.0, 0.0, 1.0]), [0.0, 0.0, -2.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_pitch_up_looks_plus_y() {
|
||||
let v = Mat4::view([0.0, 0.0, 0.0], 0.0, std::f32::consts::FRAC_PI_2);
|
||||
assert!(close(v.transform([0.0, 3.0, 0.0, 1.0]), [0.0, 0.0, -3.0, 1.0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn perspective_maps_near_far_to_0_1() {
|
||||
let p = Mat4::perspective(1.0, 4.0 / 3.0, 0.1, 100.0);
|
||||
let n = p.transform([0.0, 0.0, -0.1, 1.0]);
|
||||
let f = p.transform([0.0, 0.0, -100.0, 1.0]);
|
||||
assert!((n[2] / n[3] - 0.0).abs() < 1e-5);
|
||||
assert!((f[2] / f[3] - 1.0).abs() < 1e-4);
|
||||
// w = Abstand vor der Kamera
|
||||
assert!((n[3] - 0.1).abs() < 1e-6 && (f[3] - 100.0).abs() < 1e-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! Fenster-Frontend: winit-Loop um den wgpu-Renderer.
|
||||
//!
|
||||
//! Stand: Schritt 4 des Renderer-Plans (notes/renderer-plan.md) — Flycam
|
||||
//! über dem PS1-Szenen-Pass. WASD bewegt, Maus blickt (Klick fängt den
|
||||
//! Cursor, Escape gibt ihn frei bzw. beendet), Space/Shift hoch/runter.
|
||||
//! Als Nächstes: OBJ-Szene (Schritt 5).
|
||||
//!
|
||||
//! Steuert dieselbe [`Session`] wie die CLI: das Fenster *besitzt* sie,
|
||||
//! Terminal-Befehle kommen über einen Channel von einem stdin-Thread und
|
||||
//! werden pro Loop-Durchlauf eingespielt. Im Dialog-Modus pausiert die
|
||||
//! Welt (Sim-Uhr und Kamera stehen) — so sieht das Fenster nie einen
|
||||
//! anderen Zustand als die Konsole.
|
||||
//!
|
||||
//! Schicht-Regel wie `cli`: Geschwister von `engine`/`session`, konsumiert
|
||||
//! deren Schnittstellen — nie umgekehrt.
|
||||
|
||||
mod brush;
|
||||
mod camera;
|
||||
mod font;
|
||||
mod gpu;
|
||||
mod math;
|
||||
mod scene;
|
||||
mod sprite;
|
||||
mod ui;
|
||||
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::{DeviceEvent, DeviceId, ElementState, KeyEvent, MouseButton, WindowEvent};
|
||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
||||
use winit::keyboard::{KeyCode, PhysicalKey};
|
||||
use winit::window::{CursorGrabMode, Window, WindowId};
|
||||
|
||||
use crate::engine::player;
|
||||
use crate::engine::tga::Image;
|
||||
use crate::engine::{assets, map, tga};
|
||||
use crate::session::{FrameInput, Mode, Session};
|
||||
use camera::Camera;
|
||||
use gpu::Gpu;
|
||||
use math::Mat4;
|
||||
use scene::Mesh;
|
||||
|
||||
/// Radiant pro Maus-Pixel.
|
||||
const MOUSE_SENS: f32 = 0.0025;
|
||||
/// Welt-Einheiten pro Sekunde.
|
||||
const MOVE_SPEED: f32 = 4.0;
|
||||
/// Zeitkonstante (s) des Eye-Height-Smoothings am Boden: die Kamera gleitet
|
||||
/// vertikal sanft auf Augenhöhe nach, statt Step-up-/Slope-Sprüngen sofort zu
|
||||
/// folgen (wie Source/HL2). Größer = weicher, aber träger.
|
||||
const EYE_SMOOTH_TAU: f32 = 0.06;
|
||||
/// Maximaler vertikaler Nachlauf der Kamera hinter den Füßen (units).
|
||||
const EYE_MAX_LAG: f32 = 0.6;
|
||||
|
||||
pub fn run(mut session: Session) {
|
||||
// Init-Signal feuern, bevor das Fenster steht (kann bereits einen
|
||||
// Dialog öffnen — dann startet die Welt eben pausiert).
|
||||
for line in session.start() { println!("{line}"); }
|
||||
|
||||
// CPU-Assets dekodieren (Pull über die engine-Decoder). Das Hochladen
|
||||
// auf die GPU macht später `Gpu` — Decode (CPU) und Upload (GPU) bleiben
|
||||
// getrennt.
|
||||
let world = map::load(&assets::path("assets/maps/test.map"));
|
||||
let tex_names = brush::texture_names(&world);
|
||||
let images: Vec<Image> = tex_names.iter()
|
||||
.map(|n| tga::load(&assets::path(&format!("assets/textures/{n}.tga"))))
|
||||
.collect();
|
||||
let dims: Vec<(u32, u32)> = images.iter().map(|i| (i.width, i.height)).collect();
|
||||
let mesh = brush::build(&world, &tex_names, &dims);
|
||||
report_map(&world, &tex_names, &mesh);
|
||||
|
||||
// Sim-Welt (Collision-Brushes + Spawn) in die Session einspielen — die
|
||||
// Simulation gehört der Session, nicht dem Render-Frontend.
|
||||
session.load_world(&world);
|
||||
|
||||
// UI-Texturen, Reihenfolge = die Index-Konstanten in ui (WHITE, FONT_EGA,
|
||||
// FONT_CGA, CURSORS, ORN). Decode (CPU) bleibt in run(); Fonts, Cursor und
|
||||
// Ornament sind weiß-auf-schwarz und bekommen ihre Alpha-Maske aus der
|
||||
// Luminanz.
|
||||
let load_keyed = |p: &str| ui::key_luminance(&tga::load(&assets::path(p)));
|
||||
// Font-Atlanten zuerst keyen, daraus die Glyph-Breiten messen (Font),
|
||||
// dann dieselben Bilder in die GPU-Texturliste übernehmen.
|
||||
let ega_img = load_keyed("assets/textures/fonts/oldschool-ega-8x14.tga");
|
||||
let cga_img = load_keyed("assets/textures/fonts/oldschool-cga-8x8.tga");
|
||||
let fonts = font::Fonts {
|
||||
ega: font::Font::ega(ui::FONT_EGA, &ega_img),
|
||||
cga: font::Font::cga(ui::FONT_CGA, &cga_img),
|
||||
};
|
||||
let ui_textures: Vec<Image> = vec![
|
||||
ui::white_pixel(),
|
||||
ega_img,
|
||||
cga_img,
|
||||
load_keyed("assets/textures/ui/cursors.tga"),
|
||||
load_keyed("assets/textures/ui/panel_ornaments.tga"),
|
||||
];
|
||||
|
||||
// stdin auf einem eigenen Thread: er darf blockieren, der Main-Thread
|
||||
// (winit) nicht. Es queren nur Strings die Thread-Grenze, kein Zustand
|
||||
// — der bleibt allein auf dem Main-Thread.
|
||||
let (tx, rx) = mpsc::channel::<String>();
|
||||
std::thread::spawn(move || {
|
||||
let stdin = std::io::stdin();
|
||||
let mut buf = String::new();
|
||||
loop {
|
||||
buf.clear();
|
||||
match stdin.read_line(&mut buf) {
|
||||
Ok(0) | Err(_) => break, // EOF (Ctrl-D) → Thread endet
|
||||
Ok(_) => {
|
||||
if tx.send(buf.trim_end().to_string()).is_err() { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let event_loop = EventLoop::new().expect("winit: Event-Loop");
|
||||
// Poll statt Wait: wir rendern kontinuierlich (Spiel, kein Editor) und
|
||||
// pollen nebenbei den Befehls-Channel.
|
||||
event_loop.set_control_flow(ControlFlow::Poll);
|
||||
let mut app = App::new(session, rx, mesh, images, ui_textures, fonts);
|
||||
event_loop.run_app(&mut app).expect("winit: run");
|
||||
}
|
||||
|
||||
/// Kurzer Lade-Report: Map-Inhalt und resultierende Geometriegröße.
|
||||
fn report_map(world: &map::Map, tex_names: &[String], mesh: &Mesh) {
|
||||
let brushes: usize = world.entities.iter().map(|e| e.brushes.len()).sum();
|
||||
let faces: usize = world.entities.iter()
|
||||
.flat_map(|e| &e.brushes)
|
||||
.map(|b| b.faces.len())
|
||||
.sum();
|
||||
println!(
|
||||
"[map] {} Entities, {} Brushes, {} Faces, Texturen {:?} → {} Vertices, {} Dreiecke",
|
||||
world.entities.len(), brushes, faces, tex_names,
|
||||
mesh.verts.len(), mesh.indices.len() / 3,
|
||||
);
|
||||
}
|
||||
|
||||
/// Aktueller Eingabezustand. Tasten als gehaltene Flags (nicht Events),
|
||||
/// damit die Bewegung pro Frame mit dt skaliert; Maus-Delta wird zwischen
|
||||
/// den Frames akkumuliert und nach dem Anwenden genullt.
|
||||
#[derive(Default)]
|
||||
struct Input {
|
||||
fwd: bool,
|
||||
back: bool,
|
||||
left: bool,
|
||||
right: bool,
|
||||
up: bool,
|
||||
down: bool,
|
||||
/// Sprung-Flanke: bei Space-Druck gesetzt, nach `player::step` verbraucht.
|
||||
jump: bool,
|
||||
mouse_dx: f32,
|
||||
mouse_dy: f32,
|
||||
/// Cursor gefangen → Maus-Look aktiv.
|
||||
grabbed: bool,
|
||||
}
|
||||
|
||||
struct App {
|
||||
window: Option<Arc<Window>>,
|
||||
gpu: Option<Gpu>,
|
||||
session: Session,
|
||||
rx: Receiver<String>,
|
||||
/// Noclip-Debug-Flycam (reine Frontend-Sicht, kein Sim-Zustand).
|
||||
camera: Camera,
|
||||
/// Geglättete Kamera-Augenhöhe (Welt-Y) — siehe Eye-Height-Smoothing.
|
||||
eye_y: f32,
|
||||
input: Input,
|
||||
last: Option<Instant>,
|
||||
/// Letzte Mausposition in Fenster-Pixeln (für den UI-Cursor).
|
||||
cursor_win: [f32; 2],
|
||||
/// Linksklick steht aus, wird im nächsten redraw gegen das UI aufgelöst.
|
||||
pending_click: bool,
|
||||
/// CPU-seitige Welt-Geometrie + Texturen, in `resumed` einmal auf die
|
||||
/// GPU geladen.
|
||||
mesh: Mesh,
|
||||
images: Vec<Image>,
|
||||
ui_textures: Vec<Image>,
|
||||
fonts: font::Fonts,
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn new(
|
||||
session: Session,
|
||||
rx: Receiver<String>,
|
||||
mesh: Mesh,
|
||||
images: Vec<Image>,
|
||||
ui_textures: Vec<Image>,
|
||||
fonts: font::Fonts,
|
||||
) -> Self {
|
||||
// Kamera-Augenhöhe initial auf den Spawn des Players (gehört der Session).
|
||||
let eye_y = session.player.pos[1] + player::EYE_HEIGHT;
|
||||
Self {
|
||||
window: None,
|
||||
gpu: None,
|
||||
session,
|
||||
rx,
|
||||
// Etwas zurück und erhöht, Blick Richtung Welt-Ursprung.
|
||||
camera: Camera::new([0.0, 1.0, 4.0]),
|
||||
eye_y,
|
||||
input: Input::default(),
|
||||
last: None,
|
||||
cursor_win: [0.0, 0.0],
|
||||
pending_click: false,
|
||||
mesh,
|
||||
images,
|
||||
ui_textures,
|
||||
fonts,
|
||||
}
|
||||
}
|
||||
|
||||
// Der OS-Cursor bleibt immer versteckt (in `resumed` einmal gesetzt) —
|
||||
// wir zeichnen stets ein eigenes Sprite. `set_grab` schaltet nur die
|
||||
// Greif-Art: Locked (Flycam, relativer Blick) ↔ None (freie Maus, UI).
|
||||
fn set_grab(&mut self, grab: bool) {
|
||||
let Some(window) = &self.window else { return; };
|
||||
if grab {
|
||||
// Locked (Cursor fixiert) ist am angenehmsten, wird aber nicht
|
||||
// überall unterstützt → auf Confined zurückfallen.
|
||||
let ok = window.set_cursor_grab(CursorGrabMode::Locked)
|
||||
.or_else(|_| window.set_cursor_grab(CursorGrabMode::Confined))
|
||||
.is_ok();
|
||||
if ok { self.input.grabbed = true; }
|
||||
} else {
|
||||
let _ = window.set_cursor_grab(CursorGrabMode::None);
|
||||
self.input.grabbed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ApplicationHandler for App {
|
||||
/// winit liefert das Fenster erst hier, nicht beim Loop-Start —
|
||||
/// deshalb sind `window`/`gpu` Options statt Konstruktor-Felder.
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() { return; }
|
||||
let attrs = Window::default_attributes()
|
||||
.with_title("wds")
|
||||
// 2× interne Auflösung als Startgröße; frei resizebar.
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(
|
||||
gpu::INTERNAL_W * 2, gpu::INTERNAL_H * 2));
|
||||
let window = Arc::new(event_loop.create_window(attrs).expect("winit: Fenster"));
|
||||
// OS-Cursor dauerhaft aus — das Overlay zeichnet immer einen eigenen.
|
||||
window.set_cursor_visible(false);
|
||||
self.gpu = Some(Gpu::new(
|
||||
window.clone(),
|
||||
event_loop.owned_display_handle(),
|
||||
&self.mesh,
|
||||
&self.images,
|
||||
&self.ui_textures,
|
||||
));
|
||||
self.window = Some(window);
|
||||
}
|
||||
|
||||
/// Rohe Maus-Bewegung (unabhängig von Cursor-Position) — nur sinnvoll,
|
||||
/// solange der Cursor gefangen ist.
|
||||
fn device_event(&mut self, _el: &ActiveEventLoop, _id: DeviceId, event: DeviceEvent) {
|
||||
if let DeviceEvent::MouseMotion { delta } = event {
|
||||
if self.input.grabbed {
|
||||
self.input.mouse_dx += delta.0 as f32;
|
||||
self.input.mouse_dy += delta.1 as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Läuft einmal pro Loop-Durchlauf, nachdem die Events abgearbeitet
|
||||
/// sind: Konsolen-Befehle einspielen und den nächsten Frame anfordern.
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
while let Ok(line) = self.rx.try_recv() {
|
||||
let r = self.session.exec(&line);
|
||||
for out in r.output { println!("{out}"); }
|
||||
if r.quit { event_loop.exit(); }
|
||||
}
|
||||
if let Some(w) = &self.window { w.request_redraw(); }
|
||||
}
|
||||
|
||||
fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
|
||||
match event {
|
||||
WindowEvent::CloseRequested => event_loop.exit(),
|
||||
WindowEvent::CursorMoved { position, .. } => {
|
||||
self.cursor_win = [position.x as f32, position.y as f32];
|
||||
}
|
||||
WindowEvent::MouseInput { state: ElementState::Pressed, button: MouseButton::Left, .. } => {
|
||||
// Klick nur vormerken; der nächste redraw entscheidet (mit der
|
||||
// frisch berechneten Hover-Aktion) zwischen UI-Klick und
|
||||
// Flycam-Greifen.
|
||||
self.pending_click = true;
|
||||
}
|
||||
WindowEvent::KeyboardInput { event: key, .. } => self.on_key(event_loop, key),
|
||||
WindowEvent::Resized(size) => {
|
||||
if let Some(gpu) = &mut self.gpu {
|
||||
gpu.resize(size.width, size.height);
|
||||
}
|
||||
}
|
||||
WindowEvent::RedrawRequested => self.redraw(event_loop),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
fn on_key(&mut self, event_loop: &ActiveEventLoop, key: KeyEvent) {
|
||||
let PhysicalKey::Code(code) = key.physical_key else { return; };
|
||||
let down = key.state == ElementState::Pressed;
|
||||
|
||||
// Escape togglet das Menü über denselben exec-Trichter wie Konsole
|
||||
// und Klick. Der Lock-Zustand folgt danach automatisch dem Modus
|
||||
// (siehe redraw). Im Dialog tut Escape nichts (Dialog endet über
|
||||
// seine Choices).
|
||||
if code == KeyCode::Escape && down {
|
||||
if !matches!(self.session.mode, Mode::Dialog(_)) {
|
||||
let r = self.session.exec("menu");
|
||||
for out in r.output { println!("{out}"); }
|
||||
if r.quit { event_loop.exit(); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match code {
|
||||
KeyCode::KeyW => self.input.fwd = down,
|
||||
KeyCode::KeyS => self.input.back = down,
|
||||
KeyCode::KeyA => self.input.left = down,
|
||||
KeyCode::KeyD => self.input.right = down,
|
||||
// Space: in der Flycam „hoch" (gehalten), im Spiel Sprung (Flanke,
|
||||
// nicht Auto-Repeat). Beide Deutungen aus derselben Taste.
|
||||
KeyCode::Space => {
|
||||
self.input.up = down;
|
||||
if down && !key.repeat { self.input.jump = true; }
|
||||
}
|
||||
KeyCode::ShiftLeft => self.input.down = down,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn redraw(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// dt messen; im Dialog Kamera und Sim anhalten → Welt pausiert,
|
||||
// während die Konsole den Dialog treibt.
|
||||
let now = Instant::now();
|
||||
let dt = self.last.replace(now).map_or(0.0, |prev| (now - prev).as_secs_f32());
|
||||
|
||||
// Lock-Zustand folgt dem Modus: Spiel (Play) und Flycam (Free) fangen
|
||||
// die Maus (relativer Blick); Menü/Dialog geben sie frei fürs UI. So
|
||||
// gibt es keine parallele Lock-Logik.
|
||||
let fly = matches!(self.session.mode, Mode::Free);
|
||||
let play = matches!(self.session.mode, Mode::FirstPerson);
|
||||
let grab = fly || play;
|
||||
if grab != self.input.grabbed { self.set_grab(grab); }
|
||||
|
||||
if fly {
|
||||
// Flycam ist Frontend-Sicht: hier (nicht in der Session) bewegt.
|
||||
self.camera.apply_mouse(self.input.mouse_dx, self.input.mouse_dy, MOUSE_SENS);
|
||||
let fwd = axis(self.input.fwd, self.input.back);
|
||||
let right = axis(self.input.right, self.input.left);
|
||||
let up = axis(self.input.up, self.input.down);
|
||||
self.camera.translate(fwd, right, up, MOVE_SPEED * dt);
|
||||
} else if play {
|
||||
// Eingaben einsammeln und die Simulation in der Session treiben.
|
||||
let input = FrameInput {
|
||||
fwd: axis(self.input.fwd, self.input.back),
|
||||
right: axis(self.input.right, self.input.left),
|
||||
jump: self.input.jump,
|
||||
look_dx: self.input.mouse_dx,
|
||||
look_dy: self.input.mouse_dy,
|
||||
};
|
||||
self.session.tick(&input, dt);
|
||||
}
|
||||
// Deltas/Flanke immer verwerfen, auch im Dialog — sonst springt der
|
||||
// Blick beim Fortsetzen um die aufgestaute Bewegung.
|
||||
self.input.mouse_dx = 0.0;
|
||||
self.input.mouse_dy = 0.0;
|
||||
self.input.jump = false;
|
||||
|
||||
// Eye-Height-Smoothing: die Kamera-Augenhöhe gleitet am Boden sanft auf
|
||||
// den Zielwert nach, statt Step-up-/Slope-Kanten-Sprüngen sofort zu
|
||||
// folgen (versteckt den AABB-auf-Slope-„Pop" und macht Treppen weich).
|
||||
// In der Luft (Sprung/Fall) und außerhalb des Spiels exakt — da soll
|
||||
// die Kamera reagieren bzw. mit der Flycam synchron bleiben.
|
||||
let target_eye = self.session.player.pos[1] + player::EYE_HEIGHT;
|
||||
if play && self.session.player.grounded {
|
||||
let a = 1.0 - (-dt / EYE_SMOOTH_TAU).exp();
|
||||
self.eye_y += (target_eye - self.eye_y) * a;
|
||||
self.eye_y = self.eye_y.clamp(target_eye - EYE_MAX_LAG, target_eye + EYE_MAX_LAG);
|
||||
} else {
|
||||
self.eye_y = target_eye;
|
||||
}
|
||||
|
||||
// View: nur die Noclip-Flycam (Free) rendert aus `self.camera`. Alles
|
||||
// andere — Spiel, Menü, Dialog — sieht durch die Augen des Players
|
||||
// (geglättete Augenhöhe); Menü/Dialog frieren die Sim nur ein, die
|
||||
// Sicht bleibt die First-Person-Sicht.
|
||||
let view = if fly {
|
||||
self.camera.view()
|
||||
} else {
|
||||
let p = self.session.player.pos;
|
||||
Mat4::view([p[0], self.eye_y, p[2]], self.session.player.yaw, self.session.player.pitch)
|
||||
};
|
||||
// Mausposition über die Letterbox in interne Pixel mappen.
|
||||
let cursor = ui::Cursor {
|
||||
pos: self.gpu.as_ref()
|
||||
.map(|g| g.map_cursor(self.cursor_win))
|
||||
.unwrap_or([0.0, 0.0]),
|
||||
grabbed: self.input.grabbed,
|
||||
};
|
||||
// Overlay state-driven bauen (verzweigt nach session.mode); die
|
||||
// Hover-Aktion kommt aus der Maus-Position.
|
||||
let screen = ui::layout(
|
||||
[gpu::INTERNAL_W as f32, gpu::INTERNAL_H as f32], &self.fonts, &self.session, &cursor,
|
||||
);
|
||||
|
||||
// Vorgemerkten Klick auflösen: über einem Klickziel → dessen Aktion
|
||||
// durch denselben exec-Trichter wie die Konsole. Klick in der freien
|
||||
// Welt tut (noch) nichts — hier käme später der `use`-Raycast.
|
||||
if self.pending_click {
|
||||
self.pending_click = false;
|
||||
if let Some(action) = screen.hover_action.clone() {
|
||||
let r = self.session.exec(&action);
|
||||
for out in r.output { println!("{out}"); }
|
||||
if r.quit { event_loop.exit(); }
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(gpu) = &mut self.gpu { gpu.frame(&view, &screen.ui); }
|
||||
}
|
||||
}
|
||||
|
||||
/// Zwei gegensätzliche Tasten zu einer Achse in {-1, 0, 1}.
|
||||
fn axis(positive: bool, negative: bool) -> f32 {
|
||||
(positive as i32 - negative as i32) as f32
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Szenen-Pass: zeichnet die 3D-Welt ins interne Target.
|
||||
//!
|
||||
//! Stand Schritt 5: Geometrie + Texturen kommen vom Aufrufer (Brush-Welt
|
||||
//! aus der `.map`, siehe render::brush; Bilder aus engine::tga). Pro Textur
|
||||
//! eine Bind-Group + ein Draw-Batch (Material-Batching „pro Textur ein
|
||||
//! Draw" wie im Plan). Die Shader (scene.wgsl) sind die echten PS1-Shader.
|
||||
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
use crate::engine::tga::Image;
|
||||
use crate::render::math::Mat4;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub(crate) struct Vertex {
|
||||
pub(crate) pos: [f32; 3],
|
||||
pub(crate) uv: [f32; 2],
|
||||
}
|
||||
|
||||
/// Ein Draw-Batch: ein zusammenhängender Index-Bereich, der mit *einer*
|
||||
/// Textur gezeichnet wird.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Batch {
|
||||
pub(crate) texture: usize,
|
||||
pub(crate) start: u32,
|
||||
pub(crate) count: u32,
|
||||
}
|
||||
|
||||
/// CPU-seitige Welt-Geometrie, fertig zum Hochladen. Die Indizes sind nach
|
||||
/// Textur gruppiert; `batches` zeigt in diese Reihenfolge.
|
||||
pub(crate) struct Mesh {
|
||||
pub(crate) verts: Vec<Vertex>,
|
||||
pub(crate) indices: Vec<u32>,
|
||||
pub(crate) batches: Vec<Batch>,
|
||||
}
|
||||
|
||||
/// Spiegelt das `Uniforms`-Struct in scene.wgsl. `_pad` rundet die Größe
|
||||
/// auf 80 Byte (16er-Vielfaches), wie es die Uniform-Adressraum-Regeln
|
||||
/// von WGSL verlangen.
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniforms {
|
||||
mvp: [[f32; 4]; 4],
|
||||
half_res: [f32; 2],
|
||||
_pad: [f32; 2],
|
||||
}
|
||||
|
||||
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
|
||||
array_stride: size_of::<Vertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2],
|
||||
};
|
||||
|
||||
pub struct ScenePass {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
vbuf: wgpu::Buffer,
|
||||
ibuf: wgpu::Buffer,
|
||||
ubuf: wgpu::Buffer,
|
||||
uniform_bind: wgpu::BindGroup, // group 0
|
||||
tex_binds: Vec<wgpu::BindGroup>, // group 1, pro Textur
|
||||
batches: Vec<Batch>,
|
||||
}
|
||||
|
||||
impl ScenePass {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
color_format: wgpu::TextureFormat,
|
||||
depth_format: wgpu::TextureFormat,
|
||||
mesh: &Mesh,
|
||||
images: &[Image],
|
||||
) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("scene"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("scene.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("scene"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[VERTEX_LAYOUT],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(color_format.into())],
|
||||
}),
|
||||
// Back-face Culling: Brush-Polygone werden CCW um ihre nach
|
||||
// außen zeigende Normale gewickelt (siehe render::brush), und
|
||||
// die Z-up→Y-up-Drehung erhält die Orientierung (det +1). Also
|
||||
// sind die Außenflächen front-facing — Rückseiten und alle
|
||||
// verdeckten Innenflächen zwischen Brushes fallen weg.
|
||||
// (Front-Face bleibt der wgpu-Default CCW.)
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
..Default::default()
|
||||
},
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: depth_format,
|
||||
depth_write_enabled: Some(true),
|
||||
depth_compare: Some(wgpu::CompareFunction::Less),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let vbuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("scene vertices"),
|
||||
contents: bytemuck::cast_slice(&mesh.verts),
|
||||
usage: wgpu::BufferUsages::VERTEX,
|
||||
});
|
||||
let ibuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("scene indices"),
|
||||
contents: bytemuck::cast_slice(&mesh.indices),
|
||||
usage: wgpu::BufferUsages::INDEX,
|
||||
});
|
||||
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("scene uniforms"),
|
||||
size: size_of::<Uniforms>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("scene uniforms"),
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: ubuf.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
|
||||
// mag=Nearest hält nah dran die harten Texel (der knackige Look);
|
||||
// min/mipmap=Linear glätten nur die Verkleinerung in der Ferne und
|
||||
// blenden weich zwischen den Mip-Ebenen (kein Mip-Popping). Killt das
|
||||
// Texel-Flimmern hochauflösender Texturen auf der kleinen internen
|
||||
// Auflösung. Mipmaps werden in upload_texture per Box-Filter erzeugt.
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("scene"),
|
||||
address_mode_u: wgpu::AddressMode::Repeat,
|
||||
address_mode_v: wgpu::AddressMode::Repeat,
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Linear,
|
||||
mipmap_filter: wgpu::MipmapFilterMode::Linear,
|
||||
..Default::default()
|
||||
});
|
||||
let tex_layout = pipeline.get_bind_group_layout(1);
|
||||
let tex_binds = images.iter()
|
||||
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
pipeline, vbuf, ibuf, ubuf, uniform_bind, tex_binds,
|
||||
batches: mesh.batches.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Uniforms für diesen Frame hochladen — vor dem Render-Pass rufen.
|
||||
/// `half_res` = halbe interne Auflösung (fürs Pixel-Snap im Shader).
|
||||
pub fn prepare(&self, queue: &wgpu::Queue, mvp: &Mat4, half_res: [f32; 2]) {
|
||||
let u = Uniforms { mvp: mvp.0, half_res, _pad: [0.0; 2] };
|
||||
queue.write_buffer(&self.ubuf, 0, bytemuck::bytes_of(&u));
|
||||
}
|
||||
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass) {
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.uniform_bind, &[]);
|
||||
pass.set_vertex_buffer(0, self.vbuf.slice(..));
|
||||
pass.set_index_buffer(self.ibuf.slice(..), wgpu::IndexFormat::Uint32);
|
||||
for b in &self.batches {
|
||||
pass.set_bind_group(1, &self.tex_binds[b.texture], &[]);
|
||||
pass.draw_indexed(b.start..b.start + b.count, 0, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die zugehörige
|
||||
/// Bind-Group (Textur + Sampler, group 1) bauen. Erzeugt die volle
|
||||
/// Mipchain (bis 1×1) per Box-Filter auf der CPU und lädt jede Ebene hoch.
|
||||
fn upload_texture(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
sampler: &wgpu::Sampler,
|
||||
img: &Image,
|
||||
) -> wgpu::BindGroup {
|
||||
// floor(log2(max(w,h))) + 1 = volle Kette bis zur 1×1-Ebene.
|
||||
let mip_level_count = 32 - img.width.max(img.height).leading_zeros();
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("scene texture"),
|
||||
size: wgpu::Extent3d {
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
// Nicht-sRGB: die Quantisierung im Shader erwartet rohe Werte.
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
|
||||
// Ebene 0 ist das Originalbild; jede weitere wird aus der vorherigen
|
||||
// halbiert. `level` trägt die aktuell hochzuladenden Pixel.
|
||||
let mut level = img.rgba.clone();
|
||||
let (mut w, mut h) = (img.width, img.height);
|
||||
for mip in 0..mip_level_count {
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &texture,
|
||||
mip_level: mip,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&level,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(w * 4),
|
||||
rows_per_image: Some(h),
|
||||
},
|
||||
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
|
||||
);
|
||||
if mip + 1 < mip_level_count {
|
||||
(level, w, h) = downsample(&level, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
let view = texture.create_view(&Default::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("scene texture"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
/// Eine RGBA8-Mip-Ebene per 2×2-Box-Filter auf die nächstkleinere halbieren.
|
||||
/// Gibt die neuen Pixel samt Maßen zurück. Ungerade Maße werden via `(d+1)/2`
|
||||
/// aufgerundet (sonst ginge die letzte Spalte/Zeile verloren); die fehlende
|
||||
/// Quell-Spalte/-Zeile wird auf den Rand geklemmt, statt den Mittelwert zu
|
||||
/// verfälschen. Mittelung im (gamma-kodierten) Speicherraum — für den
|
||||
/// stilisierten Look unkritisch.
|
||||
fn downsample(src: &[u8], w: u32, h: u32) -> (Vec<u8>, u32, u32) {
|
||||
let (nw, nh) = ((w + 1) / 2, (h + 1) / 2);
|
||||
let mut dst = vec![0u8; (nw * nh * 4) as usize];
|
||||
for y in 0..nh {
|
||||
for x in 0..nw {
|
||||
let (x0, y0) = (2 * x, 2 * y);
|
||||
let x1 = (x0 + 1).min(w - 1);
|
||||
let y1 = (y0 + 1).min(h - 1);
|
||||
let at = |px: u32, py: u32| ((py * w + px) * 4) as usize;
|
||||
let samples = [at(x0, y0), at(x1, y0), at(x0, y1), at(x1, y1)];
|
||||
let di = ((y * nw + x) * 4) as usize;
|
||||
for c in 0..4 {
|
||||
let sum: u32 = samples.iter().map(|&s| src[s + c] as u32).sum();
|
||||
dst[di + c] = ((sum + 2) / 4) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
(dst, nw, nh)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
// Szenen-Pipeline: die PS1-Eigenheiten aus dem Renderer-Plan, authentisch
|
||||
// gerechnet statt nachgestellt:
|
||||
// - Pixel-Snap im Vertex-Shader → Vertex-Jitter
|
||||
// - RGB555-Quantisierung + 4×4-Bayer-Dither im Fragment-Shader
|
||||
// - 1-Bit-Alpha-Test (Cutouts), kein Blending
|
||||
//
|
||||
// UVs werden perspektivkorrekt interpoliert (@interpolate(perspective)). Das
|
||||
// klassische PS1-Textur-Wobbeln (affin, @interpolate(linear)) ist mit einer
|
||||
// frei beweglichen Kamera und großen Faces zu störend — den Look tragen
|
||||
// Pixel-Snap und Dither.
|
||||
|
||||
struct Uniforms {
|
||||
mvp: mat4x4f,
|
||||
// Halbe interne Auflösung (NDC läuft -1..1, Spanne 2 → Skalierung mit
|
||||
// der halben Auflösung trifft das Pixelraster). Kommt aus dem Renderer.
|
||||
half_res: vec2f,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
|
||||
@group(1) @binding(0) var tex: texture_2d<f32>;
|
||||
@group(1) @binding(1) var smp: sampler;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4f,
|
||||
// perspektivkorrekt interpoliert (WGSL-Default) — kein Affin-Wobbeln.
|
||||
@location(0) @interpolate(perspective) uv: vec2f,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(@location(0) pos: vec3f, @location(1) uv: vec2f) -> VsOut {
|
||||
var clip = u.mvp * vec4f(pos, 1.0);
|
||||
// Pixel-Snap: xy nach der Projektion aufs interne Raster runden und
|
||||
// zurück in den Clip-Raum. Nur vor der Kamera (w>0) — dahinter würde
|
||||
// die Division Unsinn liefern, das Hardware-Clipping übernimmt.
|
||||
if clip.w > 0.0 {
|
||||
let ndc = clip.xy / clip.w;
|
||||
clip = vec4f(round(ndc * u.half_res) / u.half_res * clip.w, clip.zw);
|
||||
}
|
||||
var out: VsOut;
|
||||
out.pos = clip;
|
||||
out.uv = uv;
|
||||
return out;
|
||||
}
|
||||
|
||||
// 4×4-Bayer-Matrix (Werte 0..15) für Ordered Dithering.
|
||||
fn bayer4(px: vec2u) -> f32 {
|
||||
var m = array<u32, 16>(
|
||||
0u, 8u, 2u, 10u,
|
||||
12u, 4u, 14u, 6u,
|
||||
3u, 11u, 1u, 9u,
|
||||
15u, 7u, 13u, 5u,
|
||||
);
|
||||
return f32(m[(px.y % 4u) * 4u + (px.x % 4u)]);
|
||||
}
|
||||
|
||||
// LOD-Bias für die Mip-Auswahl: negativ = schärfer (greift einen höher
|
||||
// aufgelösten Mip, als die UV-Ableitungen verlangen), positiv = weicher.
|
||||
// 0 = neutral. Bei -1.0 verdoppelt sich praktisch die Texelrate (mehr
|
||||
// Schärfe, etwas mehr Flimmern); -0.5 ist ein sanfter Mittelweg.
|
||||
const MIP_BIAS: f32 = -1.0;
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4f {
|
||||
let texel = textureSampleBias(tex, smp, in.uv, MIP_BIAS);
|
||||
if texel.a < 0.5 { discard; } // 1-Bit-Alpha (Cutouts), noch ungenutzt
|
||||
|
||||
// RGB555: 31 Stufen pro Kanal. Bayer-Schwelle vor dem Abrunden →
|
||||
// Verläufe zerfallen ins typische Dither-Muster.
|
||||
let t = (bayer4(vec2u(in.pos.xy)) + 0.5) / 16.0;
|
||||
let c = clamp(texel.rgb, vec3f(0.0), vec3f(1.0));
|
||||
return vec4f(floor(c * 31.0 + t) / 31.0, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
//! Sprite-Pass: das 2D-Overlay (UI) als getintete Textur-Quads.
|
||||
//!
|
||||
//! Immediate-Mode: der Vertex-Buffer wird jeden Frame neu befüllt (kleine
|
||||
//! Datenmenge). Geometrie kommt von [`crate::render::ui`] als fertige
|
||||
//! [`SpriteVertex`]-Liste + [`SpriteBatch`]es (ein Batch je Textur). Die
|
||||
//! Texturen (weißer Pixel, Font-Atlas, …) werden einmal beim Bau
|
||||
//! hochgeladen — wie beim Szenen-Pass: Decode (CPU) ≠ Upload (GPU).
|
||||
|
||||
use crate::engine::tga::Image;
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
pub(crate) struct SpriteVertex {
|
||||
/// Interne Pixel, Ursprung oben-links.
|
||||
pub(crate) pos: [f32; 2],
|
||||
pub(crate) uv: [f32; 2],
|
||||
/// Tint, mit dem Texel multipliziert (RGBA, 0..1).
|
||||
pub(crate) color: [f32; 4],
|
||||
}
|
||||
|
||||
/// Ein zusammenhängender Vertex-Bereich, der mit *einer* Textur gezeichnet
|
||||
/// wird (zwei Dreiecke je Quad, also Vielfache von 6).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct SpriteBatch {
|
||||
pub(crate) texture: usize,
|
||||
pub(crate) start: u32,
|
||||
pub(crate) count: u32,
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
|
||||
struct Uniforms {
|
||||
inv_res: [f32; 2],
|
||||
_pad: [f32; 2],
|
||||
}
|
||||
|
||||
/// Obergrenze des dynamischen Vertex-Buffers. 8192 Verts = ~1365 Quads pro
|
||||
/// Frame — reichlich für Dialog-Panels, Text und Cursor.
|
||||
const MAX_VERTS: u64 = 8192;
|
||||
|
||||
const VERTEX_LAYOUT: wgpu::VertexBufferLayout<'static> = wgpu::VertexBufferLayout {
|
||||
array_stride: size_of::<SpriteVertex>() as u64,
|
||||
step_mode: wgpu::VertexStepMode::Vertex,
|
||||
attributes: &wgpu::vertex_attr_array![0 => Float32x2, 1 => Float32x2, 2 => Float32x4],
|
||||
};
|
||||
|
||||
pub struct SpritePass {
|
||||
pipeline: wgpu::RenderPipeline,
|
||||
vbuf: wgpu::Buffer,
|
||||
uniform_bind: wgpu::BindGroup, // group 0
|
||||
tex_binds: Vec<wgpu::BindGroup>, // group 1, pro Textur
|
||||
/// Im aktuellen Frame hochgeladene Vertex-Anzahl (für `draw`-Schutz).
|
||||
loaded: u32,
|
||||
}
|
||||
|
||||
impl SpritePass {
|
||||
pub fn new(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
color_format: wgpu::TextureFormat,
|
||||
depth_format: wgpu::TextureFormat,
|
||||
internal: [f32; 2],
|
||||
textures: &[Image],
|
||||
) -> Self {
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("sprite"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("sprite.wgsl").into()),
|
||||
});
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("sprite"),
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &shader,
|
||||
entry_point: Some("vs_main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[VERTEX_LAYOUT],
|
||||
},
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &shader,
|
||||
entry_point: Some("fs_main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: color_format,
|
||||
// Standard-Alpha-Blending fürs Overlay.
|
||||
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
primitive: wgpu::PrimitiveState::default(),
|
||||
// Der interne Pass hat ein Tiefen-Attachment → die Pipeline muss
|
||||
// ein kompatibles Format deklarieren. UI testet/schreibt aber
|
||||
// keine Tiefe (Always, kein Write) → liegt immer obenauf.
|
||||
depth_stencil: Some(wgpu::DepthStencilState {
|
||||
format: depth_format,
|
||||
depth_write_enabled: Some(false),
|
||||
depth_compare: Some(wgpu::CompareFunction::Always),
|
||||
stencil: wgpu::StencilState::default(),
|
||||
bias: wgpu::DepthBiasState::default(),
|
||||
}),
|
||||
multisample: wgpu::MultisampleState::default(),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let vbuf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("sprite vertices"),
|
||||
size: MAX_VERTS * size_of::<SpriteVertex>() as u64,
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
let ubuf = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("sprite uniforms"),
|
||||
size: size_of::<Uniforms>() as u64,
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let u = Uniforms { inv_res: [1.0 / internal[0], 1.0 / internal[1]], _pad: [0.0; 2] };
|
||||
queue.write_buffer(&ubuf, 0, bytemuck::bytes_of(&u));
|
||||
let uniform_bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("sprite uniforms"),
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry { binding: 0, resource: ubuf.as_entire_binding() }],
|
||||
});
|
||||
|
||||
// Nearest + Clamp: harte Pixel, kein Wrap an den Atlas-Rändern.
|
||||
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||
label: Some("sprite nearest"),
|
||||
address_mode_u: wgpu::AddressMode::ClampToEdge,
|
||||
address_mode_v: wgpu::AddressMode::ClampToEdge,
|
||||
mag_filter: wgpu::FilterMode::Nearest,
|
||||
min_filter: wgpu::FilterMode::Nearest,
|
||||
..Default::default()
|
||||
});
|
||||
let tex_layout = pipeline.get_bind_group_layout(1);
|
||||
let tex_binds = textures.iter()
|
||||
.map(|img| upload_texture(device, queue, &tex_layout, &sampler, img))
|
||||
.collect();
|
||||
|
||||
Self { pipeline, vbuf, uniform_bind, tex_binds, loaded: 0 }
|
||||
}
|
||||
|
||||
/// Vertex-Daten dieses Frames hochladen — vor dem Render-Pass rufen.
|
||||
pub fn prepare(&mut self, queue: &wgpu::Queue, verts: &[SpriteVertex]) {
|
||||
let n = (verts.len() as u64).min(MAX_VERTS) as usize;
|
||||
queue.write_buffer(&self.vbuf, 0, bytemuck::cast_slice(&verts[..n]));
|
||||
self.loaded = n as u32;
|
||||
}
|
||||
|
||||
pub fn draw(&self, pass: &mut wgpu::RenderPass, batches: &[SpriteBatch]) {
|
||||
if self.loaded == 0 { return; }
|
||||
pass.set_pipeline(&self.pipeline);
|
||||
pass.set_bind_group(0, &self.uniform_bind, &[]);
|
||||
pass.set_vertex_buffer(0, self.vbuf.slice(..));
|
||||
for b in batches {
|
||||
if b.start + b.count > self.loaded { continue; }
|
||||
pass.set_bind_group(1, &self.tex_binds[b.texture], &[]);
|
||||
pass.draw(b.start..b.start + b.count, 0..1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ein RGBA8-`Image` als GPU-Textur hochladen und die Bind-Group (Textur +
|
||||
/// Sampler, group 1) bauen.
|
||||
fn upload_texture(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
layout: &wgpu::BindGroupLayout,
|
||||
sampler: &wgpu::Sampler,
|
||||
img: &Image,
|
||||
) -> wgpu::BindGroup {
|
||||
let size = wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 };
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: Some("sprite texture"),
|
||||
size,
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||
view_formats: &[],
|
||||
});
|
||||
queue.write_texture(
|
||||
wgpu::TexelCopyTextureInfo {
|
||||
texture: &texture,
|
||||
mip_level: 0,
|
||||
origin: wgpu::Origin3d::ZERO,
|
||||
aspect: wgpu::TextureAspect::All,
|
||||
},
|
||||
&img.rgba,
|
||||
wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(img.width * 4),
|
||||
rows_per_image: Some(img.height),
|
||||
},
|
||||
size,
|
||||
);
|
||||
let view = texture.create_view(&Default::default());
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("sprite texture"),
|
||||
layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) },
|
||||
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(sampler) },
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// 2D-Sprite-Pass für das UI-Overlay: texturierte Quads in internen Pixeln
|
||||
// (Ursprung oben-links), getintet. Zeichnet ins interne Target *nach* der
|
||||
// Szene, ohne Tiefentest → liegt immer obenauf. Echtes Alpha-Blending
|
||||
// (anders als der 1-Bit-Alphatest der Welt), damit Panels und Glyph-Ränder
|
||||
// weich überblenden.
|
||||
|
||||
struct Uniforms {
|
||||
inv_res: vec2f, // 1/Breite, 1/Höhe des internen Targets
|
||||
_pad: vec2f,
|
||||
};
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
|
||||
@group(1) @binding(0) var tex: texture_2d<f32>;
|
||||
@group(1) @binding(1) var smp: sampler;
|
||||
|
||||
struct VsOut {
|
||||
@builtin(position) pos: vec4f,
|
||||
@location(0) uv: vec2f,
|
||||
@location(1) color: vec4f,
|
||||
};
|
||||
|
||||
@vertex
|
||||
fn vs_main(
|
||||
@location(0) pos: vec2f,
|
||||
@location(1) uv: vec2f,
|
||||
@location(2) color: vec4f,
|
||||
) -> VsOut {
|
||||
var o: VsOut;
|
||||
// Pixel (oben-links) → NDC (y nach oben).
|
||||
let ndc = vec2f(
|
||||
pos.x * u.inv_res.x * 2.0 - 1.0,
|
||||
1.0 - pos.y * u.inv_res.y * 2.0,
|
||||
);
|
||||
o.pos = vec4f(ndc, 0.0, 1.0);
|
||||
o.uv = uv;
|
||||
o.color = color;
|
||||
return o;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fs_main(in: VsOut) -> @location(0) vec4f {
|
||||
// Textur × Tint. Für Solid-Fills zeigt die UV auf einen weißen Texel,
|
||||
// dann ist die Farbe rein der Tint.
|
||||
return textureSample(tex, smp, in.uv) * in.color;
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
//! UI-Overlay: **state-driven** und **immediate-mode**.
|
||||
//!
|
||||
//! [`layout`] ist eine reine Funktion des [`Session`]-Zustands — es hält
|
||||
//! keinen eigenen mutierbaren Zustand (anders als irl3ds Widget-Bäume, die
|
||||
//! neben dem Spielzustand desyncen konnten). Jeden Frame wird aus dem
|
||||
//! aktuellen [`Mode`] neu gebaut: Dialog → Panel + Choice-Buttons, Menü →
|
||||
//! Menü-Buttons, usw. Klicks erzeugen denselben Eingabe-String, den auch
|
||||
//! CLI und stdin liefern, und laufen durch dasselbe `Session::exec` — ein
|
||||
//! einziger Trichter in den State, kein UI-Backdoor.
|
||||
//!
|
||||
//! Koordinaten sind interne Pixel (das Overlay wird vor dem Blit gezeichnet,
|
||||
//! teilt also den Lo-Fi-Look). Gezeichnet wird über [`crate::render::sprite`].
|
||||
|
||||
use crate::engine::tga::Image;
|
||||
use crate::render::font::{self, Font, Fonts};
|
||||
use crate::render::sprite::{SpriteBatch, SpriteVertex};
|
||||
use crate::session::{Dialog, Mode, Session};
|
||||
|
||||
/// Feste Textur-Indizes im Sprite-Pass — Reihenfolge = `ui_textures` in
|
||||
/// render::run. `WHITE` ist ein 1×1-Weiß-Texel für Solid-Fills (Farbe kommt
|
||||
/// rein aus dem Tint), `FONT_*` die CP437-Glyph-Atlanten, `CURSORS` das
|
||||
/// 2×2-Raster der 16×16-Cursor, `ORN` der 9-Slice-Ornamentrahmen.
|
||||
pub(crate) const WHITE: usize = 0;
|
||||
pub(crate) const FONT_EGA: usize = 1;
|
||||
pub(crate) const FONT_CGA: usize = 2;
|
||||
pub(crate) const CURSORS: usize = 3;
|
||||
pub(crate) const ORN: usize = 4;
|
||||
|
||||
/// Cursor-Indizes im 2×2-Raster (Pivot mittig je 16×16-Zelle).
|
||||
pub(crate) const CUR_MOUSE: usize = 0; // Maus, normal
|
||||
pub(crate) const CUR_MOUSE_INTERACT: usize = 1; // Maus, über Klickziel
|
||||
pub(crate) const CUR_HUD: usize = 2; // First-Person-Pointer, normal
|
||||
// Die interagierbare HUD-Variante (3) kommt mit dem Welt-Raycast.
|
||||
#[allow(dead_code)] pub(crate) const CUR_HUD_INTERACT: usize = 3;
|
||||
|
||||
const CURSOR_PX: f32 = 16.0;
|
||||
const ORN_CORNER: f32 = 8.0; // Eckgröße im Ornament-Atlas (und im Panel)
|
||||
|
||||
/// 1×1 weißes RGBA8-`Image` für getintete Solid-Flächen.
|
||||
pub(crate) fn white_pixel() -> Image {
|
||||
Image { width: 1, height: 1, rgba: vec![255, 255, 255, 255] }
|
||||
}
|
||||
|
||||
/// Weiß-auf-schwarz-Sprite (Font, Cursor, Ornament) → getintbare Maske:
|
||||
/// rgb = weiß, alpha = Luminanz. So blendet der Sprite-Pass den schwarzen
|
||||
/// Hintergrund weg und der Tint setzt die Farbe. Reine CPU-Nachbearbeitung
|
||||
/// beim Laden.
|
||||
pub(crate) fn key_luminance(img: &Image) -> Image {
|
||||
let mut rgba = img.rgba.clone();
|
||||
for px in rgba.chunks_mut(4) {
|
||||
let lum = px[0].max(px[1]).max(px[2]);
|
||||
px[0] = 255; px[1] = 255; px[2] = 255; px[3] = lum;
|
||||
}
|
||||
Image { width: img.width, height: img.height, rgba }
|
||||
}
|
||||
|
||||
/// Cursor, wie ihn die App pro Frame an [`layout`] gibt.
|
||||
pub(crate) struct Cursor {
|
||||
/// Position in internen Pixeln (über die Letterbox gemappt).
|
||||
pub(crate) pos: [f32; 2],
|
||||
/// Maus gefangen (Flycam aktiv) → First-Person-Pointer mittig statt
|
||||
/// Maus-Cursor an `pos`.
|
||||
pub(crate) grabbed: bool,
|
||||
}
|
||||
|
||||
/// Akkumulierte Overlay-Geometrie eines Frames, fertig für den Sprite-Pass.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Ui {
|
||||
pub(crate) verts: Vec<SpriteVertex>,
|
||||
pub(crate) batches: Vec<SpriteBatch>,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
/// Ein getintetes Quad. `rect` = (x, y, w, h) in internen Pixeln
|
||||
/// (oben-links), `uv` = (u0, v0, u1, v1) in 0..1. Aufeinanderfolgende
|
||||
/// Quads derselben Textur landen im selben Batch.
|
||||
pub(crate) fn quad(&mut self, tex: usize, rect: [f32; 4], uv: [f32; 4], color: [f32; 4]) {
|
||||
let [x, y, w, h] = rect;
|
||||
let [u0, v0, u1, v1] = uv;
|
||||
let base = self.verts.len() as u32;
|
||||
// Zwei Dreiecke (TL, BL, BR / TL, BR, TR).
|
||||
let tl = SpriteVertex { pos: [x, y ], uv: [u0, v0], color };
|
||||
let bl = SpriteVertex { pos: [x, y + h], uv: [u0, v1], color };
|
||||
let br = SpriteVertex { pos: [x + w, y + h], uv: [u1, v1], color };
|
||||
let tr = SpriteVertex { pos: [x + w, y ], uv: [u1, v0], color };
|
||||
self.verts.extend([tl, bl, br, tl, br, tr]);
|
||||
|
||||
// An letzten Batch anhängen, wenn gleiche Textur, sonst neuen öffnen.
|
||||
match self.batches.last_mut() {
|
||||
Some(b) if b.texture == tex => b.count += 6,
|
||||
_ => self.batches.push(SpriteBatch { texture: tex, start: base, count: 6 }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Getintetes Solid-Rechteck (weißer Texel).
|
||||
pub(crate) fn fill(&mut self, rect: [f32; 4], color: [f32; 4]) {
|
||||
self.quad(WHITE, rect, [0.0, 0.0, 1.0, 1.0], color);
|
||||
}
|
||||
|
||||
/// Eine Textzeile ab (x, y) oben-links in `font`, proportional gesetzt
|
||||
/// (variable Laufweite, leere Glyphen werden übersprungen). `\n` springt
|
||||
/// eine Zeile weiter. Gibt die x-Endposition zurück.
|
||||
pub(crate) fn text(&mut self, font: &Font, x: f32, y: f32, s: &str, color: [f32; 4]) -> f32 {
|
||||
let (mut cx, mut cy) = (x, y);
|
||||
for ch in s.chars() {
|
||||
if ch == '\n' { cx = x; cy += font.glyph_h; continue; }
|
||||
let code = font::cp437(ch);
|
||||
if let Some((uv, w)) = font.glyph_quad(code) {
|
||||
self.quad(font.tex(), [cx, cy, w, font.glyph_h], uv, color);
|
||||
}
|
||||
cx += font.advance(code);
|
||||
}
|
||||
cx
|
||||
}
|
||||
|
||||
/// Ein Cursor-Sprite (16×16, Pivot mittig) an `pos`, getintet.
|
||||
pub(crate) fn cursor(&mut self, idx: usize, pos: [f32; 2], color: [f32; 4]) {
|
||||
let rect = [pos[0] - CURSOR_PX * 0.5, pos[1] - CURSOR_PX * 0.5, CURSOR_PX, CURSOR_PX];
|
||||
self.quad(CURSORS, rect, cursor_uv(idx), color);
|
||||
}
|
||||
|
||||
/// Opakes Panel mit Ornamentrahmen. `rect` = Außenbounds. Erst der
|
||||
/// Hintergrund (Solid, getintet), dann der 9-Slice-Rahmen darüber: feste
|
||||
/// 8×8-Ecken, die Kanten dazwischen gekachelt bis zur nächsten Ecke.
|
||||
pub(crate) fn panel(&mut self, rect: [f32; 4], bg: [f32; 4], frame: [f32; 4]) {
|
||||
self.fill(rect, bg);
|
||||
self.ornament_frame(rect, frame);
|
||||
}
|
||||
|
||||
fn ornament_frame(&mut self, rect: [f32; 4], color: [f32; 4]) {
|
||||
let [x, y, w, h] = rect;
|
||||
let c = ORN_CORNER;
|
||||
let a = ORN_ATLAS;
|
||||
// Vier Ecken (1:1 aus dem Atlas).
|
||||
self.quad(ORN, [x, y, c, c], orn_uv(0.0, 0.0, c, c), color);
|
||||
self.quad(ORN, [x + w - c, y, c, c], orn_uv(a - c, 0.0, c, c), color);
|
||||
self.quad(ORN, [x, y + h - c, c, c], orn_uv(0.0, a - c, c, c), color);
|
||||
self.quad(ORN, [x + w - c, y + h - c, c, c], orn_uv(a - c, a - c, c, c), color);
|
||||
// Kanten zwischen den Ecken kacheln.
|
||||
self.tile_h(x + c, x + w - c, y, c, [c, 0.0], color); // oben
|
||||
self.tile_h(x + c, x + w - c, y + h - c, c, [c, a - c], color); // unten
|
||||
self.tile_v(y + c, y + h - c, x, c, [0.0, c], color); // links
|
||||
self.tile_v(y + c, y + h - c, x + w - c, c, [a - c, c], color); // rechts
|
||||
}
|
||||
|
||||
/// Horizontale Kante von `x0` bis `x1` bei `y` (Höhe `c`) mit dem
|
||||
/// `c`-breiten Atlas-Sample ab `src` (Pixel) kacheln; letzte Kachel wird
|
||||
/// passend beschnitten.
|
||||
fn tile_h(&mut self, x0: f32, x1: f32, y: f32, c: f32, src: [f32; 2], color: [f32; 4]) {
|
||||
let mut cx = x0;
|
||||
while cx < x1 - 0.5 {
|
||||
let seg = (x1 - cx).min(ORN_EDGE);
|
||||
self.quad(ORN, [cx, y, seg, c], orn_uv(src[0], src[1], seg, c), color);
|
||||
cx += seg;
|
||||
}
|
||||
}
|
||||
|
||||
fn tile_v(&mut self, y0: f32, y1: f32, x: f32, c: f32, src: [f32; 2], color: [f32; 4]) {
|
||||
let mut cy = y0;
|
||||
while cy < y1 - 0.5 {
|
||||
let seg = (y1 - cy).min(ORN_EDGE);
|
||||
self.quad(ORN, [x, cy, c, seg], orn_uv(src[0], src[1], c, seg), color);
|
||||
cy += seg;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ORN_ATLAS: f32 = 32.0; // Ornament-Atlasgröße (32×32)
|
||||
const ORN_EDGE: f32 = 16.0; // Kanten-Sample-Länge (zwischen den 8px-Ecken)
|
||||
|
||||
/// Atlas-Pixel-Rechteck → UV (0..1) im 32×32-Ornamentatlas.
|
||||
fn orn_uv(x: f32, y: f32, w: f32, h: f32) -> [f32; 4] {
|
||||
[x / ORN_ATLAS, y / ORN_ATLAS, (x + w) / ORN_ATLAS, (y + h) / ORN_ATLAS]
|
||||
}
|
||||
|
||||
/// UV-Rechteck einer Cursor-Zelle im 2×2-Raster (16×16 je Zelle, Atlas 32×32).
|
||||
fn cursor_uv(idx: usize) -> [f32; 4] {
|
||||
let col = (idx % 2) as f32;
|
||||
let row = (idx / 2) as f32;
|
||||
let (x0, y0) = (col * CURSOR_PX, row * CURSOR_PX);
|
||||
[x0 / 32.0, y0 / 32.0, (x0 + CURSOR_PX) / 32.0, (y0 + CURSOR_PX) / 32.0]
|
||||
}
|
||||
|
||||
/// Ergebnis eines Frames: die Overlay-Geometrie und — falls der Cursor über
|
||||
/// einem Klickziel steht — dessen Aktion (Eingabe-String für `Session::exec`).
|
||||
pub(crate) struct Screen {
|
||||
pub(crate) ui: Ui,
|
||||
pub(crate) hover_action: Option<String>,
|
||||
}
|
||||
|
||||
/// Punkt in Rechteck (x, y, w, h)?
|
||||
fn in_rect(p: [f32; 2], r: [f32; 4]) -> bool {
|
||||
p[0] >= r[0] && p[0] < r[0] + r[2] && p[1] >= r[1] && p[1] < r[1] + r[3]
|
||||
}
|
||||
|
||||
/// Ein klickbarer Button: Fläche + zentriertes Label. Steht der Cursor
|
||||
/// darüber, wird er hervorgehoben und seine `action` in `hover` vermerkt
|
||||
/// (der Klick wirkt dann auf genau diese Aktion).
|
||||
fn button(
|
||||
ui: &mut Ui, font: &Font, rect: [f32; 4], label: &str, action: &str,
|
||||
cur: [f32; 2], hover: &mut Option<String>,
|
||||
) {
|
||||
let hot = in_rect(cur, rect);
|
||||
if hot { *hover = Some(action.to_string()); }
|
||||
let bg = if hot { [0.34, 0.22, 0.46, 1.0] } else { [0.15, 0.12, 0.22, 1.0] };
|
||||
ui.fill(rect, bg);
|
||||
let tx = rect[0] + (rect[2] - font.text_width(label)) * 0.5;
|
||||
let ty = rect[1] + (rect[3] - font.glyph_h) * 0.5;
|
||||
ui.text(font, tx.round(), ty.round(), label, [0.95, 0.95, 1.0, 1.0]);
|
||||
}
|
||||
|
||||
/// Innenabstand vom Panelrand zum Inhalt: 8px Ornamentrahmen + 3px Luft.
|
||||
const INSET: f32 = ORN_CORNER + 3.0;
|
||||
const PANEL_BG: [f32; 4] = [0.06, 0.05, 0.10, 1.0];
|
||||
const PANEL_FRAME: [f32; 4] = [0.85, 0.75, 0.95, 1.0];
|
||||
const TEXT: [f32; 4] = [0.92, 0.92, 1.0, 1.0];
|
||||
|
||||
/// Das Overlay für den aktuellen Frame bauen — verzweigt nach `session.mode`.
|
||||
pub(crate) fn layout(internal: [f32; 2], fonts: &Fonts, session: &Session, cur: &Cursor) -> Screen {
|
||||
let mut ui = Ui::default();
|
||||
let mut hover: Option<String> = None;
|
||||
let f = &fonts.cga; // CGA 8×8 ist der UI-Standard-Font.
|
||||
|
||||
// Im Flycam-Modus (Maus gefangen) zählt die freie Sicht → kein Panel.
|
||||
// Sonst je nach Modus Menü oder Dialog. Hit-Tests laufen nur bei freier
|
||||
// Maus (im Dialog ist die Maus ohnehin nie gefangen).
|
||||
if !cur.grabbed {
|
||||
match &session.mode {
|
||||
// Spiel und Flycam: nur HUD/Cursor, kein Panel (beide laufen
|
||||
// ohnehin gefangen — dieser Zweig greift nur im Übergang).
|
||||
Mode::FirstPerson | Mode::Free => {}
|
||||
Mode::Menu => menu(&mut ui, f, internal, cur.pos, &mut hover),
|
||||
Mode::Dialog(d) => dialog(&mut ui, f, internal, d, cur.pos, &mut hover),
|
||||
}
|
||||
}
|
||||
|
||||
// Cursor zuletzt → über allem. Gefangen → HUD-Pointer mittig; sonst
|
||||
// Maus-Cursor, „interagierbar" wenn er über einem Klickziel steht.
|
||||
if cur.grabbed {
|
||||
ui.cursor(CUR_HUD, [internal[0] * 0.5, internal[1] * 0.5], [1.0; 4]);
|
||||
} else {
|
||||
let idx = if hover.is_some() { CUR_MOUSE_INTERACT } else { CUR_MOUSE };
|
||||
ui.cursor(idx, cur.pos, [1.0; 4]);
|
||||
}
|
||||
|
||||
Screen { ui, hover_action: hover }
|
||||
}
|
||||
|
||||
/// Menu-Modus: das (noch demohafte) Menü, zentriert.
|
||||
fn menu(ui: &mut Ui, f: &Font, internal: [f32; 2], cur: [f32; 2], hover: &mut Option<String>) {
|
||||
let items = [
|
||||
("Fortsetzen", "menu"), // schließt das Menü → ins Spiel (Play)
|
||||
("Hilfe", "help"),
|
||||
("KV anzeigen", "kv"),
|
||||
("Beenden", "quit"),
|
||||
];
|
||||
let (bw, bh, gap) = (112.0, 14.0, 4.0);
|
||||
let title_h = f.glyph_h;
|
||||
let body_h = title_h + gap + items.len() as f32 * bh + (items.len() as f32 - 1.0) * gap;
|
||||
let pw = bw + 2.0 * INSET;
|
||||
let ph = body_h + 2.0 * INSET;
|
||||
let px = ((internal[0] - pw) * 0.5).round();
|
||||
let py = ((internal[1] - ph) * 0.5).round();
|
||||
|
||||
ui.panel([px, py, pw, ph], PANEL_BG, PANEL_FRAME);
|
||||
ui.text(f, px + INSET, py + INSET, "Menü", [0.85, 0.8, 0.6, 1.0]);
|
||||
|
||||
let bx = px + INSET;
|
||||
let mut by = py + INSET + title_h + gap;
|
||||
for (label, action) in items {
|
||||
button(ui, f, [bx, by, bw, bh], label, action, cur, hover);
|
||||
by += bh + gap;
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog-Modus: Textpanel unten, darunter ein Button je Choice. Ohne Choices
|
||||
/// ein „Weiter"-Button, dessen Aktion die Leereingabe ist (blättert weiter) —
|
||||
/// derselbe Pfad wie Enter im Terminal (`dialog_input`).
|
||||
fn dialog(ui: &mut Ui, f: &Font, internal: [f32; 2], d: &Dialog, cur: [f32; 2], hover: &mut Option<String>) {
|
||||
let margin = 16.0;
|
||||
let (bh, gap) = (14.0, 4.0);
|
||||
let pw = internal[0] - 2.0 * margin;
|
||||
let line_h = f.glyph_h + 2.0;
|
||||
|
||||
// Text auf die Panelbreite umbrechen.
|
||||
let lines = wrap(f, &d.text, pw - 2.0 * INSET);
|
||||
let text_h = lines.len() as f32 * line_h;
|
||||
|
||||
// Buttonzeilen: je Choice eine, mindestens die „Weiter"-Zeile.
|
||||
let n = d.choices.len().max(1) as f32;
|
||||
let buttons_h = n * bh + (n - 1.0) * gap;
|
||||
|
||||
let ph = 2.0 * INSET + text_h + gap + buttons_h;
|
||||
let py = (internal[1] - margin - ph).max(margin).round();
|
||||
let px = margin;
|
||||
|
||||
ui.panel([px, py, pw, ph], PANEL_BG, PANEL_FRAME);
|
||||
|
||||
let mut ty = py + INSET;
|
||||
for line in &lines {
|
||||
ui.text(f, px + INSET, ty, line, TEXT);
|
||||
ty += line_h;
|
||||
}
|
||||
|
||||
let (cbx, cbw) = (px + INSET, pw - 2.0 * INSET);
|
||||
let mut cby = ty + gap;
|
||||
if d.choices.is_empty() {
|
||||
button(ui, f, [cbx, cby, cbw, bh], "Weiter", "", cur, hover);
|
||||
} else {
|
||||
for (i, opt) in d.choices.iter().enumerate() {
|
||||
let label = format!("{}) {opt}", i + 1);
|
||||
button(ui, f, [cbx, cby, cbw, bh], &label, &(i + 1).to_string(), cur, hover);
|
||||
cby += bh + gap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Text in Zeilen umbrechen, die in `max_w` Pixel passen. Vorhandene
|
||||
/// `\n` sind harte Umbrüche; sonst wird wortweise gegriffen. Ein Wort breiter
|
||||
/// als `max_w` bleibt allein in seiner Zeile (Überlauf statt Endlosschleife).
|
||||
fn wrap(font: &Font, text: &str, max_w: f32) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
for para in text.split('\n') {
|
||||
let mut line = String::new();
|
||||
for word in para.split_whitespace() {
|
||||
let trial = if line.is_empty() { word.to_string() } else { format!("{line} {word}") };
|
||||
if line.is_empty() || font.text_width(&trial) <= max_w {
|
||||
line = trial;
|
||||
} else {
|
||||
lines.push(std::mem::take(&mut line));
|
||||
line = word.to_string();
|
||||
}
|
||||
}
|
||||
lines.push(line);
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn same_texture_quads_merge_into_one_batch() {
|
||||
let mut ui = Ui::default();
|
||||
ui.fill([0.0, 0.0, 10.0, 10.0], [1.0; 4]);
|
||||
ui.fill([20.0, 0.0, 10.0, 10.0], [1.0; 4]);
|
||||
assert_eq!(ui.verts.len(), 12); // 2 Quads · 6 Verts
|
||||
assert_eq!(ui.batches.len(), 1); // gleiche Textur → ein Batch
|
||||
assert_eq!(ui.batches[0].count, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_textures_split_batches() {
|
||||
let mut ui = Ui::default();
|
||||
ui.fill([0.0, 0.0, 10.0, 10.0], [1.0; 4]); // WHITE
|
||||
ui.quad(1, [0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 1.0, 1.0], [1.0; 4]); // Textur 1
|
||||
ui.fill([0.0, 0.0, 10.0, 10.0], [1.0; 4]); // WHITE wieder
|
||||
assert_eq!(ui.batches.len(), 3);
|
||||
assert_eq!((ui.batches[0].start, ui.batches[1].start, ui.batches[2].start), (0, 6, 12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_luminance_white_opaque_black_transparent() {
|
||||
let img = Image { width: 2, height: 1, rgba: vec![255, 255, 255, 255, 0, 0, 0, 255] };
|
||||
let k = key_luminance(&img);
|
||||
assert_eq!(&k.rgba[0..4], &[255, 255, 255, 255]); // weiß → opak
|
||||
assert_eq!(&k.rgba[4..8], &[255, 255, 255, 0]); // schwarz → transparent
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_registers_hover_only_when_cursor_inside() {
|
||||
// Leerer 256×64-Atlas → CGA-Font mit lauter leeren Glyphen; text_width
|
||||
// bleibt endlich, Button funktioniert trotzdem.
|
||||
let atlas = Image { width: 256, height: 64, rgba: vec![0; 256 * 64 * 4] };
|
||||
let font = Font::cga(FONT_CGA, &atlas);
|
||||
let rect = [10.0, 10.0, 100.0, 20.0];
|
||||
|
||||
let mut ui = Ui::default();
|
||||
let mut hover = None;
|
||||
button(&mut ui, &font, rect, "X", "do_x", [50.0, 15.0], &mut hover);
|
||||
assert_eq!(hover.as_deref(), Some("do_x"));
|
||||
|
||||
let mut outside = None;
|
||||
button(&mut ui, &font, rect, "X", "do_x", [200.0, 15.0], &mut outside);
|
||||
assert!(outside.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_breaks_on_width_and_hard_newlines() {
|
||||
// Leerer Atlas → jedes Zeichen rückt um space_adv (cga: 4px) vor.
|
||||
let atlas = Image { width: 256, height: 64, rgba: vec![0; 256 * 64 * 4] };
|
||||
let f = Font::cga(FONT_CGA, &atlas);
|
||||
// 3-Zeichen-Wörter = 12px. Bei max 12 passt je ein Wort pro Zeile.
|
||||
assert_eq!(wrap(&f, "aaa bbb ccc", 12.0), vec!["aaa", "bbb", "ccc"]);
|
||||
// Bei max 28 passen zwei ("aaa bbb" = 7·4 = 28).
|
||||
assert_eq!(wrap(&f, "aaa bbb ccc", 28.0), vec!["aaa bbb", "ccc"]);
|
||||
// Harte Umbrüche bleiben erhalten.
|
||||
assert_eq!(wrap(&f, "a\nb", 999.0), vec!["a", "b"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_uv_picks_quadrant() {
|
||||
// idx 3 = (col 1, row 1) → rechte untere 16×16-Zelle.
|
||||
assert_eq!(cursor_uv(3), [0.5, 0.5, 1.0, 1.0]);
|
||||
assert_eq!(cursor_uv(0), [0.0, 0.0, 0.5, 0.5]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ornament_frame_tiles_edges_and_places_corners() {
|
||||
let mut ui = Ui::default();
|
||||
// 48×48-Panel: Kanten je 48-16=32 px → 32/16 = 2 Kacheln pro Seite.
|
||||
// Erwartung: 4 Ecken + 4·2 Kanten = 12 Ornament-Quads (nach dem fill).
|
||||
ui.panel([0.0, 0.0, 48.0, 48.0], [0.0; 4], [1.0; 4]);
|
||||
let orn_quads = ui.verts.len() / 6 - 1; // minus das Hintergrund-fill
|
||||
assert_eq!(orn_quads, 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
use crate::engine::collision::CollisionWorld;
|
||||
use crate::engine::game::{Action, Game, ModeTarget};
|
||||
use crate::engine::ink::StoryState;
|
||||
use crate::engine::map::{self, Map};
|
||||
use crate::engine::player::Player;
|
||||
use crate::engine::{kv, signals, story_ctrl};
|
||||
|
||||
/// Maus-Empfindlichkeit der Spieler-Sicht (Radiant/Pixel).
|
||||
const LOOK_SENS: f32 = 0.0025;
|
||||
|
||||
pub enum Mode {
|
||||
FirstPerson,
|
||||
Free,
|
||||
Menu,
|
||||
Dialog(Dialog),
|
||||
}
|
||||
|
||||
pub struct Dialog {
|
||||
// Wird vom Panel-Renderer der UI-Phase gelesen; bis dahin läuft der
|
||||
// Dialogtext über die Konsolen-Ausgabe (siehe `step`).
|
||||
#[allow(dead_code)]
|
||||
pub text: String,
|
||||
pub choices: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
pub game: Game,
|
||||
pub mode: Mode,
|
||||
pub player: Player,
|
||||
pub collision: CollisionWorld,
|
||||
signals_path: String,
|
||||
}
|
||||
|
||||
/// Kontinuierliche Eingabe für einen Simulationsschritt — vom Frontend pro
|
||||
/// Frame gefüllt. Gegenstück zu [`ExecResult`]/`exec` (diskret). `look_*`
|
||||
/// sind rohe Maus-Pixel, `fwd`/`right` Tastenachsen in [-1, 1].
|
||||
#[derive(Default)]
|
||||
pub struct FrameInput {
|
||||
pub fwd: f32,
|
||||
pub right: f32,
|
||||
pub jump: bool,
|
||||
pub look_dx: f32,
|
||||
pub look_dy: f32,
|
||||
}
|
||||
|
||||
/// Ergebnis einer Eingabezeile: Ausgabezeilen plus, ob das Frontend
|
||||
/// beenden soll (`quit`/`exit`).
|
||||
pub struct ExecResult {
|
||||
pub output: Vec<String>,
|
||||
pub quit: bool,
|
||||
}
|
||||
|
||||
impl ExecResult {
|
||||
fn lines(output: Vec<String>) -> Self { Self { output, quit: false } }
|
||||
fn empty() -> Self { Self { output: Vec::new(), quit: false } }
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(signals_path: String) -> Self {
|
||||
let game = Game::new(signals::load_signals(&signals_path));
|
||||
Self {
|
||||
game,
|
||||
mode: Mode::Menu,
|
||||
// Default-Welt, bis `load_world` eine Map einspielt (die CLI
|
||||
// braucht keine — sie tickt nie).
|
||||
player: Player::new([0.0, 1.0, 0.0]),
|
||||
collision: CollisionWorld::empty(),
|
||||
signals_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// Welt-Geometrie aus der Map laden: Collision-Brushes bauen und den
|
||||
/// Spieler an `info_player_start` setzen. Einmal vom Fenster-Frontend beim
|
||||
/// Start gerufen.
|
||||
pub fn load_world(&mut self, map: &Map) {
|
||||
self.collision = CollisionWorld::build(map);
|
||||
self.player = Player::new(player_spawn(map));
|
||||
}
|
||||
|
||||
/// Einen Simulationsschritt treiben — das kontinuierliche Gegenstück zu
|
||||
/// `exec`. Nur im Spielmodus bewegt sich der Spieler; Menü/Dialog/Flycam
|
||||
/// pausieren die Sim (die Flycam ist reine Frontend-Sicht).
|
||||
pub fn tick(&mut self, input: &FrameInput, dt: f32) {
|
||||
if matches!(self.mode, Mode::FirstPerson) {
|
||||
self.player.look(input.look_dx, input.look_dy, LOOK_SENS);
|
||||
self.player.step(&self.collision, input.fwd, input.right, input.jump, dt);
|
||||
}
|
||||
}
|
||||
|
||||
/// Reserviertes `[init]`-Signal feuern (KV-Defaults, bevor etwas läuft).
|
||||
/// Eigene Methode statt im Konstruktor, damit das Frontend die Ausgabe
|
||||
/// darstellen kann — und damit `init` bereits einen Dialog öffnen darf.
|
||||
pub fn start(&mut self) -> Vec<String> {
|
||||
self.fire("init", None)
|
||||
}
|
||||
|
||||
/// Eine Eingabezeile verarbeiten. Im Dialog treibt sie den Dialog,
|
||||
/// sonst ist sie ein Engine-Befehl. Dieselbe Eingabequelle, je nach
|
||||
/// `mode` unterschiedlich gedeutet — so steuert Terminal *und* (später)
|
||||
/// Klick denselben Dialog ohne Sonderpfad.
|
||||
pub fn exec(&mut self, line: &str) -> ExecResult {
|
||||
match self.mode {
|
||||
Mode::Dialog(_) => self.dialog_input(line),
|
||||
Mode::FirstPerson | Mode::Free | Mode::Menu => self.command(line),
|
||||
}
|
||||
}
|
||||
|
||||
fn command(&mut self, line: &str) -> ExecResult {
|
||||
let line = line.trim();
|
||||
match line {
|
||||
"" => ExecResult::empty(),
|
||||
"quit" | "exit" => ExecResult { output: Vec::new(), quit: true },
|
||||
"menu" => {
|
||||
// Menü öffnen bzw. ins Spiel zurück. „Fortsetzen" landet im
|
||||
// Spielmodus (Play), nicht in der Debug-Flycam; aus Play oder
|
||||
// Free öffnet es das Menü. (Dialog wird anders geroutet.)
|
||||
let t = match self.mode {
|
||||
Mode::Menu => ModeTarget::Play,
|
||||
_ => ModeTarget::Menu,
|
||||
};
|
||||
self.set_mode(t);
|
||||
ExecResult::empty()
|
||||
}
|
||||
"noclip" => {
|
||||
// Debug: zwischen First-Person (Play) und freier Flycam (Free)
|
||||
// umschalten.
|
||||
let t = match self.mode {
|
||||
Mode::FirstPerson => ModeTarget::Free,
|
||||
_ => ModeTarget::Play,
|
||||
};
|
||||
self.set_mode(t);
|
||||
ExecResult::empty()
|
||||
}
|
||||
"help" => ExecResult::lines(help()),
|
||||
"kv" => ExecResult::lines(self.dump_kv()),
|
||||
"reload" => {
|
||||
self.game.signals = signals::load_signals(&self.signals_path);
|
||||
ExecResult::lines(vec![
|
||||
format!("{} Signale geladen.", self.game.signals.len()),
|
||||
])
|
||||
}
|
||||
_ => {
|
||||
if let Some(m) = line.strip_prefix("mode ") {
|
||||
// Modus explizit setzen — über dieselbe Action wie das
|
||||
// `mode`-Verb aus signals.toml/Ink-Tags, damit CLI und
|
||||
// Signals genau einen Wechsel-Pfad teilen.
|
||||
match ModeTarget::parse(m) {
|
||||
Some(t) => {
|
||||
self.game.actions.push(Action::SetMode(t));
|
||||
ExecResult::lines(self.drain_actions())
|
||||
}
|
||||
None => ExecResult::lines(vec![
|
||||
"Modus: play | free | menu".into(),
|
||||
]),
|
||||
}
|
||||
} else if let Some(sig) = line.strip_prefix("signal ") {
|
||||
ExecResult::lines(self.fire(sig.trim(), None))
|
||||
} else if let Some(name) = line.strip_prefix("use ") {
|
||||
// Objekt-Interaktion simulieren: Signal ist der gestrippte
|
||||
// Name, $self der volle — wie der LMB-Klick-Pfad in irl3d.
|
||||
let name = name.trim();
|
||||
let key = signals::signal_key(name).to_string();
|
||||
ExecResult::lines(self.fire(&key, Some(name.to_string())))
|
||||
} else {
|
||||
ExecResult::lines(vec![
|
||||
format!("unbekannter Befehl: {line:?} — `help` für Befehle"),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dialog_input(&mut self, line: &str) -> ExecResult {
|
||||
let Mode::Dialog(d) = &self.mode else { return ExecResult::empty(); };
|
||||
let sel = if d.choices.is_empty() {
|
||||
None // reiner Text → Leerzeile/Enter blättert weiter
|
||||
} else {
|
||||
match line.trim().parse::<usize>() {
|
||||
Ok(n) if (1..=d.choices.len()).contains(&n) => Some(n - 1),
|
||||
_ => return ExecResult::lines(vec![format!(" (1..{})", d.choices.len())]),
|
||||
}
|
||||
};
|
||||
let mut out = self.step(sel);
|
||||
// Ein start_ink-Tag kann während step() eine neue Story in den Slot
|
||||
// geladen haben, während die alte auf End lief (mode → Play). Falls
|
||||
// das passiert ist, sofort den ersten Schritt der neuen Story ziehen
|
||||
// statt den Spieler im Play-Modus hängen zu lassen.
|
||||
if self.game.story.is_some() && matches!(self.mode, Mode::FirstPerson) {
|
||||
out.extend(self.step(None));
|
||||
}
|
||||
ExecResult::lines(out)
|
||||
}
|
||||
|
||||
/// Signal dispatchen, deferred Actions einsammeln und — falls dadurch
|
||||
/// eine Story startete — den ersten Dialogschritt ziehen.
|
||||
fn fire(&mut self, signal: &str, instance: Option<String>) -> Vec<String> {
|
||||
{
|
||||
let mut ctx = self.game.action_ctx(instance);
|
||||
signals::dispatch(signal, &mut ctx);
|
||||
}
|
||||
let mut out = self.drain_actions();
|
||||
if self.game.story.is_some() {
|
||||
out.extend(self.step(None));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Einen Story-Schritt treiben und `mode` daran anpassen. `sel` ist die
|
||||
/// Choice-Auswahl bzw. `None` für ersten Schritt / Text-Weiterblättern.
|
||||
/// Tags gehen wie gehabt zurück in den Dispatcher (siehe story_ctrl).
|
||||
fn step(&mut self, sel: Option<usize>) -> Vec<String> {
|
||||
let state = {
|
||||
let mut ctx = self.game.action_ctx(None);
|
||||
let (state, tags) = story_ctrl::advance(sel, &mut ctx);
|
||||
for t in &tags { signals::dispatch(t, &mut ctx); }
|
||||
state
|
||||
};
|
||||
let mut out = self.drain_actions();
|
||||
match state {
|
||||
None | Some(StoryState::End) => {
|
||||
self.mode = Mode::FirstPerson;
|
||||
}
|
||||
Some(StoryState::Text(text)) => {
|
||||
out.push(text.clone());
|
||||
self.mode = Mode::Dialog(Dialog { text, choices: Vec::new() });
|
||||
}
|
||||
Some(StoryState::Choice { prompt, options }) => {
|
||||
if !prompt.is_empty() { out.push(prompt.clone()); }
|
||||
for (i, o) in options.iter().enumerate() {
|
||||
out.push(format!(" {}) {o}", i + 1));
|
||||
}
|
||||
self.mode = Mode::Dialog(Dialog { text: prompt, choices: options });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn drain_actions(&mut self) -> Vec<String> {
|
||||
// Erst aus der Queue ziehen (gibt den `&mut self.game`-Borrow frei),
|
||||
// dann anwenden — `SetMode` braucht `&mut self.mode`.
|
||||
let actions: Vec<Action> = self.game.actions.drain(..).collect();
|
||||
let mut out = Vec::with_capacity(actions.len());
|
||||
for a in actions {
|
||||
match a {
|
||||
Action::HideObject(n) => out.push(format!("[action] hide_object {n}")),
|
||||
Action::PlaySound(n) => out.push(format!("[action] play_sound {n}")),
|
||||
Action::SetMode(t) => {
|
||||
self.set_mode(t);
|
||||
out.push(format!("[action] mode {}", t.label()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Frontend-neutrales [`ModeTarget`] auf den konkreten [`Mode`] mappen.
|
||||
/// Einziger Schreibpfad für die nicht-Dialog-Modi — `menu`/`noclip`/`mode`
|
||||
/// und die `SetMode`-Action laufen alle hier durch. (Der Dialog-Modus
|
||||
/// entsteht ausschließlich in `step` aus dem Story-Ablauf.)
|
||||
fn set_mode(&mut self, t: ModeTarget) {
|
||||
self.mode = match t {
|
||||
ModeTarget::Play => Mode::FirstPerson,
|
||||
ModeTarget::Free => Mode::Free,
|
||||
ModeTarget::Menu => Mode::Menu,
|
||||
};
|
||||
}
|
||||
|
||||
fn dump_kv(&self) -> Vec<String> {
|
||||
let mut keys: Vec<&String> = self.game.kv.keys().collect();
|
||||
keys.sort();
|
||||
keys.into_iter()
|
||||
.map(|k| format!(" {k} = {}", kv::format_value(&self.game.kv[k])))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn-Fußpunkt (Engine-Koords) aus `info_player_start`; sonst ein Default.
|
||||
fn player_spawn(map: &Map) -> [f32; 3] {
|
||||
for e in &map.entities {
|
||||
if e.classname() == Some("info_player_start") {
|
||||
if let Some(origin) = e.props.get("origin") {
|
||||
let v: Vec<f32> = origin.split_whitespace().filter_map(|s| s.parse().ok()).collect();
|
||||
if let [x, y, z] = v[..] {
|
||||
return map::to_engine([x, y, z]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
[0.0, 1.0, 0.0]
|
||||
}
|
||||
|
||||
fn help() -> Vec<String> {
|
||||
vec![
|
||||
" signal <s> Signal feuern oder Action direkt ausführen".into(),
|
||||
" (z.B. `signal Cube`, `signal set has_key true`)".into(),
|
||||
" use <instance> Objekt-Interaktion simulieren — `use Mushroom.005`".into(),
|
||||
" feuert Signal `Mushroom` mit $self = Mushroom.005".into(),
|
||||
" kv KV-Store anzeigen".into(),
|
||||
" menu Menü öffnen / ins Spiel zurück".into(),
|
||||
" noclip zwischen First-Person und Debug-Flycam wechseln".into(),
|
||||
" mode <m> Modus direkt setzen: play | free | menu".into(),
|
||||
" reload signals.toml neu laden".into(),
|
||||
" quit beenden".into(),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Fehlender Pfad → leere Signal-Table (load_signals schluckt den Fehler),
|
||||
// also keine Asset-Abhängigkeit für diese Tests. Der Konstruktor startet
|
||||
// im Menü (Titelbildschirm); Tests des Befehlspfads setzen vorab `Free`.
|
||||
fn empty_session() -> Session { Session::new("/nonexistent/signals.toml".into()) }
|
||||
|
||||
#[test]
|
||||
fn starts_in_menu() {
|
||||
assert!(matches!(empty_session().mode, Mode::Menu));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_sets_flag() {
|
||||
let mut s = empty_session();
|
||||
assert!(s.exec("quit").quit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_command_mutates_kv_and_stays_free() {
|
||||
let mut s = empty_session();
|
||||
s.mode = Mode::Free;
|
||||
// Unbekanntes Signal fällt auf den Builtin-Pfad durch → `set`.
|
||||
let r = s.exec("signal set has_key true");
|
||||
assert!(!r.quit);
|
||||
assert!(s.game.kv["has_key"].coerce_to_bool().unwrap());
|
||||
assert!(matches!(s.mode, Mode::Free));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_command_opens_and_resumes_to_play() {
|
||||
let mut s = empty_session();
|
||||
s.mode = Mode::Free;
|
||||
s.exec("menu");
|
||||
assert!(matches!(s.mode, Mode::Menu));
|
||||
s.exec("menu"); // „Fortsetzen" → ins Spiel
|
||||
assert!(matches!(s.mode, Mode::FirstPerson));
|
||||
s.exec("menu"); // aus dem Spiel wieder ins Menü
|
||||
assert!(matches!(s.mode, Mode::Menu));
|
||||
// Im Menü laufen Engine-Befehle weiter (gleiche Route wie Play/Free).
|
||||
s.exec("signal set in_menu true");
|
||||
assert!(s.game.kv["in_menu"].coerce_to_bool().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_command_sets_mode_directly() {
|
||||
let mut s = empty_session();
|
||||
s.exec("mode play");
|
||||
assert!(matches!(s.mode, Mode::FirstPerson));
|
||||
s.exec("mode free");
|
||||
assert!(matches!(s.mode, Mode::Free));
|
||||
s.exec("mode menu");
|
||||
assert!(matches!(s.mode, Mode::Menu));
|
||||
// Müll-Argument lässt den Modus stehen und meldet die Nutzung.
|
||||
let r = s.exec("mode dialog");
|
||||
assert!(matches!(s.mode, Mode::Menu));
|
||||
assert_eq!(r.output.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_can_switch_mode() {
|
||||
let mut s = empty_session();
|
||||
s.mode = Mode::Free;
|
||||
// `mode`-Verb direkt als Action (Ink-Tag-/signals.toml-Pfad).
|
||||
s.exec("signal mode play");
|
||||
assert!(matches!(s.mode, Mode::FirstPerson));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn noclip_toggles_play_and_free() {
|
||||
let mut s = empty_session();
|
||||
s.mode = Mode::FirstPerson;
|
||||
s.exec("noclip");
|
||||
assert!(matches!(s.mode, Mode::Free));
|
||||
s.exec("noclip");
|
||||
assert!(matches!(s.mode, Mode::FirstPerson));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_reports_and_stays_free() {
|
||||
let mut s = empty_session();
|
||||
s.mode = Mode::Free;
|
||||
let r = s.exec("frobnicate");
|
||||
assert_eq!(r.output.len(), 1);
|
||||
assert!(matches!(s.mode, Mode::Free));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
# tools/
|
||||
|
||||
Hilfsskripte für die Asset-Pipeline. Sie sind **nicht** Teil der Engine —
|
||||
die Engine liest nur die fertig gebauten Dateien unter `assets/`.
|
||||
|
||||
## build-dialogues.sh
|
||||
|
||||
Baut alle Ink-Dialoge zu den `.ink.json`, die das Spiel liest.
|
||||
|
||||
```sh
|
||||
./tools/build-dialogues.sh # nur Geändertes neu bauen
|
||||
./tools/build-dialogues.sh -f # alles neu bauen
|
||||
```
|
||||
|
||||
Workflow für die Autorin: `.ink`-Dateien in `assets/interactions/`
|
||||
schreiben, Skript laufen lassen, fertig. Die `.ink.json` landen direkt
|
||||
neben den `.ink` und werden mit ins Repo eingecheckt, damit ein frischer
|
||||
Clone ohne Toolchain spielbar ist.
|
||||
|
||||
**Automatisch beim Build:** `cargo build`/`cargo run` ruft dieses Skript
|
||||
über `build.rs` selbst auf, sobald sich eine `.ink` geändert hat.
|
||||
|
||||
### inklecate
|
||||
|
||||
Das Skript sucht den Ink-Compiler in dieser Reihenfolge:
|
||||
|
||||
1. `$INKLECATE` (Pfad zum Binary)
|
||||
2. `tools/inklecate` (mitgeliefert)
|
||||
3. `inklecate` im `PATH`
|
||||
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Baut alle Ink-Dialoge zu .ink.json, die das Spiel lesen kann.
|
||||
#
|
||||
# Für die Autorin: Schreib deine Dialoge als .ink-Dateien in
|
||||
# assets/interactions/
|
||||
# und führ danach dieses Skript aus:
|
||||
# ./tools/build-dialogues.sh
|
||||
# Fertig — das Spiel liest die erzeugten .ink.json.
|
||||
#
|
||||
# Standardmäßig wird nur neu gebaut, was sich seit dem letzten Mal
|
||||
# geändert hat. Mit -f / --force wird alles neu gebaut.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Repo-Wurzel relativ zum Skript bestimmen — egal, von wo aufgerufen.
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
root="$(cd "$script_dir/.." && pwd)"
|
||||
ink_dir="$root/assets/interactions"
|
||||
|
||||
force=0
|
||||
case "${1:-}" in
|
||||
-f|--force) force=1 ;;
|
||||
"") ;;
|
||||
*) echo "Unbekannte Option: $1 (nur -f/--force)" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
# inklecate finden: $INKLECATE → mitgeliefertes tools/inklecate → PATH.
|
||||
find_inklecate() {
|
||||
if [ -n "${INKLECATE:-}" ] && [ -x "$INKLECATE" ]; then
|
||||
echo "$INKLECATE"; return 0
|
||||
fi
|
||||
if [ -x "$script_dir/inklecate" ]; then
|
||||
echo "$script_dir/inklecate"; return 0
|
||||
fi
|
||||
if command -v inklecate >/dev/null 2>&1; then
|
||||
command -v inklecate; return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
if ! inklecate="$(find_inklecate)"; then
|
||||
cat >&2 <<'EOF'
|
||||
Fehler: inklecate nicht gefunden.
|
||||
Lege das Linux-Binary nach tools/inklecate (chmod +x),
|
||||
setze $INKLECATE auf den Pfad, oder installiere es in den PATH.
|
||||
Quelle: https://github.com/inkle/ink/releases
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$ink_dir" ]; then
|
||||
echo "Kein Dialog-Ordner: $ink_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Prüft eine .ink-Datei auf Zeichen außerhalb von CP437 und gibt sie aus.
|
||||
# Exit-Code 1 wenn Treffer gefunden, sonst 0.
|
||||
check_cp437() {
|
||||
python3 - "$1" <<'PYEOF'
|
||||
import sys
|
||||
path = sys.argv[1]
|
||||
cp437 = set(bytes(range(256)).decode('cp437', errors='replace'))
|
||||
hits = []
|
||||
with open(path, encoding='utf-8') as f:
|
||||
for lineno, line in enumerate(f, 1):
|
||||
for ch in line:
|
||||
if ord(ch) > 127 and ch not in cp437:
|
||||
hits.append(f' Z.{lineno}: U+{ord(ch):04X} {repr(ch)}')
|
||||
for h in hits:
|
||||
print(h)
|
||||
sys.exit(1 if hits else 0)
|
||||
PYEOF
|
||||
}
|
||||
|
||||
built=0 skipped=0 failed=0
|
||||
shopt -s nullglob
|
||||
for src in "$ink_dir"/*.ink; do
|
||||
out="${src%.ink}.ink.json"
|
||||
name="$(basename "$src")"
|
||||
|
||||
# CP437-Warnung: läuft immer, unabhängig vom Build-Status.
|
||||
if ! cp437_warn="$(check_cp437 "$src")"; then
|
||||
printf ' WARNUNG %s -- Nicht-CP437-Zeichen:\n' "$name"
|
||||
printf '%s\n' "$cp437_warn"
|
||||
fi
|
||||
|
||||
# Inkrementell: überspringen, wenn Ausgabe existiert und neuer ist.
|
||||
if [ "$force" -eq 0 ] && [ -f "$out" ] && [ "$out" -nt "$src" ]; then
|
||||
printf ' aktuell %s\n' "$name"
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
# inklecate-Ausgabe einsammeln; nur bei Problemen/Warnungen zeigen.
|
||||
if log="$("$inklecate" -o "$out" "$src" 2>&1)"; then
|
||||
printf ' gebaut %s\n' "$name"
|
||||
built=$((built + 1))
|
||||
[ -n "$log" ] && printf '%s\n' "$log" | sed 's/^/ /'
|
||||
else
|
||||
printf ' FEHLER %s\n' "$name"
|
||||
printf '%s\n' "$log" | sed 's/^/ /'
|
||||
failed=$((failed + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
echo "—— $built gebaut, $skipped aktuell, $failed fehlgeschlagen ——"
|
||||
[ "$failed" -eq 0 ]
|
||||