@kawaijs/parser 0.1.11 → 0.1.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/dist/compiler.d.ts +3 -0
- package/dist/compiler.d.ts.map +1 -1
- package/dist/compiler.js +200 -64
- package/dist/compiler.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lexer.d.ts.map +1 -1
- package/dist/lexer.js +51 -10
- package/dist/lexer.js.map +1 -1
- package/dist/parser.d.ts +16 -0
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +493 -6
- package/dist/parser.js.map +1 -1
- package/dist/suggest.d.ts +11 -0
- package/dist/suggest.d.ts.map +1 -0
- package/dist/suggest.js +85 -0
- package/dist/suggest.js.map +1 -0
- package/dist/token.d.ts +2 -1
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +45 -28
- package/dist/token.js.map +1 -1
- package/dist/validator.d.ts +20 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +226 -0
- package/dist/validator.js.map +1 -0
- package/package.json +10 -2
package/dist/parser.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createLocation } from '@kawaijs/ast';
|
|
2
2
|
import { KawaError } from './diagnostic.js';
|
|
3
|
+
import { unexpectedStatementHint } from './suggest.js';
|
|
3
4
|
import { Lexer } from './lexer.js';
|
|
4
5
|
export class Parser {
|
|
5
6
|
tokens;
|
|
@@ -40,6 +41,8 @@ export class Parser {
|
|
|
40
41
|
switch (token.type) {
|
|
41
42
|
case 'CHARACTER':
|
|
42
43
|
return this.parseCharacterDecl();
|
|
44
|
+
case 'DEFINE':
|
|
45
|
+
return this.parseDefineDecl();
|
|
43
46
|
case 'LABEL':
|
|
44
47
|
return this.parseLabelDecl();
|
|
45
48
|
case 'SCENE':
|
|
@@ -52,6 +55,8 @@ export class Parser {
|
|
|
52
55
|
return this.parseMenuStmt();
|
|
53
56
|
case 'JUMP':
|
|
54
57
|
return this.parseJumpStmt();
|
|
58
|
+
case 'CALL':
|
|
59
|
+
return this.parseCallStmt();
|
|
55
60
|
case 'RETURN':
|
|
56
61
|
return this.parseReturnStmt();
|
|
57
62
|
case 'SET':
|
|
@@ -62,6 +67,32 @@ export class Parser {
|
|
|
62
67
|
return this.parsePlayStmt();
|
|
63
68
|
case 'STOP':
|
|
64
69
|
return this.parseStopStmt();
|
|
70
|
+
case 'VFX':
|
|
71
|
+
return this.parseVfxStmt();
|
|
72
|
+
case 'CAMERA':
|
|
73
|
+
return this.parseCameraStmt();
|
|
74
|
+
case 'PAUSE':
|
|
75
|
+
return this.parsePauseStmt();
|
|
76
|
+
case 'CG':
|
|
77
|
+
return this.parseCgStmt();
|
|
78
|
+
case 'INPUT':
|
|
79
|
+
return this.parseInputStmt();
|
|
80
|
+
case 'WINDOW':
|
|
81
|
+
return this.parseWindowStmt();
|
|
82
|
+
case 'THEME':
|
|
83
|
+
return this.parseThemeStmt();
|
|
84
|
+
case 'STYLE':
|
|
85
|
+
return this.parseStyleStmt();
|
|
86
|
+
case 'HOTSPOT':
|
|
87
|
+
return this.parseHotspotStmt();
|
|
88
|
+
case 'LAYER':
|
|
89
|
+
return this.parseLayerStmt();
|
|
90
|
+
case 'ANIMATE':
|
|
91
|
+
return this.parseAnimateStmt();
|
|
92
|
+
case 'UNLOCK':
|
|
93
|
+
return this.parseUnlockStmt();
|
|
94
|
+
case 'LANG':
|
|
95
|
+
return this.parseLangStmt();
|
|
65
96
|
case 'STRING':
|
|
66
97
|
// Narration without speaker identifier
|
|
67
98
|
return this.parseDialogueStmt();
|
|
@@ -74,9 +105,10 @@ export class Parser {
|
|
|
74
105
|
}
|
|
75
106
|
throw new KawaError({
|
|
76
107
|
code: 'E0100',
|
|
77
|
-
message: `Unexpected
|
|
108
|
+
message: `Unexpected statement '${token.value}'.`,
|
|
78
109
|
severity: 'error',
|
|
79
|
-
loc: token.loc
|
|
110
|
+
loc: token.loc,
|
|
111
|
+
hint: unexpectedStatementHint(token.value)
|
|
80
112
|
});
|
|
81
113
|
}
|
|
82
114
|
default:
|
|
@@ -84,7 +116,8 @@ export class Parser {
|
|
|
84
116
|
code: 'E0101',
|
|
85
117
|
message: `Unexpected token '${token.value}' (${token.type})`,
|
|
86
118
|
severity: 'error',
|
|
87
|
-
loc: token.loc
|
|
119
|
+
loc: token.loc,
|
|
120
|
+
hint: unexpectedStatementHint(token.value)
|
|
88
121
|
});
|
|
89
122
|
}
|
|
90
123
|
}
|
|
@@ -154,17 +187,40 @@ export class Parser {
|
|
|
154
187
|
const startTok = this.consume('SHOW', 'Expected "show" keyword');
|
|
155
188
|
const charTok = this.consume('IDENTIFIER', 'Expected character name after "show"');
|
|
156
189
|
let expression;
|
|
157
|
-
if (this.check('IDENTIFIER') &&
|
|
190
|
+
if (this.check('IDENTIFIER') &&
|
|
191
|
+
!['at', 'with', 'z', 'layer'].includes(this.peek().value.toLowerCase())) {
|
|
158
192
|
expression = this.advance().value;
|
|
159
193
|
}
|
|
160
194
|
let position;
|
|
161
195
|
if (this.match('AT')) {
|
|
162
196
|
position = this.consume('IDENTIFIER', 'Expected position identifier after "at" (e.g. left, center, right)').value;
|
|
163
197
|
}
|
|
198
|
+
let layer;
|
|
199
|
+
let z;
|
|
200
|
+
// Optional `layer <name>` and/or `z <number>` in either order
|
|
201
|
+
while (!this.check('NEWLINE') && !this.check('DEDENT') && !this.check('EOF') && !this.check('WITH')) {
|
|
202
|
+
if (this.match('LAYER') || (this.check('IDENTIFIER') && this.peek().value.toLowerCase() === 'layer')) {
|
|
203
|
+
if (this.check('IDENTIFIER') && this.peek().value.toLowerCase() === 'layer') {
|
|
204
|
+
this.advance();
|
|
205
|
+
}
|
|
206
|
+
layer = this.check('STRING')
|
|
207
|
+
? this.advance().value
|
|
208
|
+
: this.consume('IDENTIFIER', 'Expected layer name after "layer"').value;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (this.check('IDENTIFIER') && this.peek().value.toLowerCase() === 'z') {
|
|
212
|
+
this.advance();
|
|
213
|
+
const zTok = this.consume('NUMBER', 'Expected z-index number after "z"');
|
|
214
|
+
z = Number(zTok.value);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
164
219
|
let transition;
|
|
165
220
|
if (this.match('WITH')) {
|
|
166
221
|
transition = this.parseTransitionName();
|
|
167
222
|
}
|
|
223
|
+
// Allow z/layer also after with (rare) — already handled above before with
|
|
168
224
|
this.consumeOptionalNewline();
|
|
169
225
|
return {
|
|
170
226
|
type: 'ShowStmt',
|
|
@@ -172,6 +228,8 @@ export class Parser {
|
|
|
172
228
|
expression,
|
|
173
229
|
position,
|
|
174
230
|
transition,
|
|
231
|
+
layer,
|
|
232
|
+
z: Number.isFinite(z) ? z : undefined,
|
|
175
233
|
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
176
234
|
};
|
|
177
235
|
}
|
|
@@ -209,6 +267,11 @@ export class Parser {
|
|
|
209
267
|
speaker = spkTok.value;
|
|
210
268
|
startLoc = spkTok.loc;
|
|
211
269
|
}
|
|
270
|
+
else if (this.check('STRING') && this.peek(1).type === 'STRING') {
|
|
271
|
+
const spkTok = this.advance();
|
|
272
|
+
speaker = spkTok.value;
|
|
273
|
+
startLoc = spkTok.loc;
|
|
274
|
+
}
|
|
212
275
|
const textTok = this.consume('STRING', 'Expected dialogue string');
|
|
213
276
|
this.consumeOptionalNewline();
|
|
214
277
|
return {
|
|
@@ -229,12 +292,17 @@ export class Parser {
|
|
|
229
292
|
if (this.check('DEDENT') || this.isAtEnd())
|
|
230
293
|
break;
|
|
231
294
|
const choiceTextTok = this.consume('STRING', 'Expected choice option string (e.g. "Say hello":)');
|
|
295
|
+
let condition;
|
|
296
|
+
if (this.match('IF')) {
|
|
297
|
+
condition = this.readUntilColon().trim();
|
|
298
|
+
}
|
|
232
299
|
this.consume('COLON', 'Expected ":" after choice string');
|
|
233
300
|
this.consumeOptionalNewline();
|
|
234
301
|
const choiceBody = this.parseBlock();
|
|
235
302
|
choices.push({
|
|
236
303
|
type: 'ChoiceItem',
|
|
237
304
|
text: choiceTextTok.value,
|
|
305
|
+
condition,
|
|
238
306
|
body: choiceBody,
|
|
239
307
|
loc: createLocation(this.file, choiceTextTok.loc.start, this.previousLocation().end)
|
|
240
308
|
});
|
|
@@ -257,6 +325,16 @@ export class Parser {
|
|
|
257
325
|
loc: createLocation(this.file, startTok.loc.start, labelTok.loc.end)
|
|
258
326
|
};
|
|
259
327
|
}
|
|
328
|
+
parseCallStmt() {
|
|
329
|
+
const startTok = this.consume('CALL', 'Expected "call" keyword');
|
|
330
|
+
const labelTok = this.consume('IDENTIFIER', 'Expected target label name after "call"');
|
|
331
|
+
this.consumeOptionalNewline();
|
|
332
|
+
return {
|
|
333
|
+
type: 'CallStmt',
|
|
334
|
+
targetLabel: labelTok.value,
|
|
335
|
+
loc: createLocation(this.file, startTok.loc.start, labelTok.loc.end)
|
|
336
|
+
};
|
|
337
|
+
}
|
|
260
338
|
parseReturnStmt() {
|
|
261
339
|
const startTok = this.consume('RETURN', 'Expected "return" keyword');
|
|
262
340
|
this.consumeOptionalNewline();
|
|
@@ -287,8 +365,10 @@ export class Parser {
|
|
|
287
365
|
});
|
|
288
366
|
}
|
|
289
367
|
let value;
|
|
368
|
+
let isVariable = false;
|
|
290
369
|
if (this.check('STRING')) {
|
|
291
370
|
value = this.advance().value;
|
|
371
|
+
isVariable = false;
|
|
292
372
|
}
|
|
293
373
|
else if (this.check('NUMBER')) {
|
|
294
374
|
value = Number(this.advance().value);
|
|
@@ -298,6 +378,7 @@ export class Parser {
|
|
|
298
378
|
}
|
|
299
379
|
else if (this.check('IDENTIFIER')) {
|
|
300
380
|
value = this.advance().value;
|
|
381
|
+
isVariable = true;
|
|
301
382
|
}
|
|
302
383
|
else {
|
|
303
384
|
throw new KawaError({
|
|
@@ -313,6 +394,7 @@ export class Parser {
|
|
|
313
394
|
variable: varTok.value,
|
|
314
395
|
operator,
|
|
315
396
|
value,
|
|
397
|
+
isVariable,
|
|
316
398
|
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
317
399
|
};
|
|
318
400
|
}
|
|
@@ -368,7 +450,15 @@ export class Parser {
|
|
|
368
450
|
loc: channelTok.loc
|
|
369
451
|
});
|
|
370
452
|
}
|
|
371
|
-
|
|
453
|
+
if (!this.check('IDENTIFIER') && !this.check('STRING') && !this.checkSoftIdentifier()) {
|
|
454
|
+
throw new KawaError({
|
|
455
|
+
code: 'E0104',
|
|
456
|
+
message: 'Expected audio track name (identifier or string literal) after audio channel',
|
|
457
|
+
severity: 'error',
|
|
458
|
+
loc: this.currentLocation()
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
const trackTok = this.advance();
|
|
372
462
|
let fade;
|
|
373
463
|
let loop = channelTok.type === 'MUSIC';
|
|
374
464
|
while (this.check('FADEIN') || this.check('LOOP')) {
|
|
@@ -412,6 +502,363 @@ export class Parser {
|
|
|
412
502
|
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
413
503
|
};
|
|
414
504
|
}
|
|
505
|
+
parseVfxStmt() {
|
|
506
|
+
const startTok = this.consume('VFX', 'Expected "vfx" keyword');
|
|
507
|
+
const effectTok = this.advance();
|
|
508
|
+
const effectName = effectTok.value.toLowerCase();
|
|
509
|
+
const validEffects = ['rain', 'snow', 'sakura', 'fog', 'tint', 'stop'];
|
|
510
|
+
if (!validEffects.includes(effectName)) {
|
|
511
|
+
throw new KawaError({
|
|
512
|
+
code: 'E0107',
|
|
513
|
+
message: `Invalid VFX effect '${effectTok.value}'. Expected one of: ${validEffects.join(', ')}`,
|
|
514
|
+
severity: 'error',
|
|
515
|
+
loc: effectTok.loc
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
let intensity;
|
|
519
|
+
let color;
|
|
520
|
+
if (effectName === 'tint') {
|
|
521
|
+
if (this.check('COLOR')) {
|
|
522
|
+
color = this.advance().value;
|
|
523
|
+
}
|
|
524
|
+
else if (this.check('STRING')) {
|
|
525
|
+
color = this.advance().value;
|
|
526
|
+
}
|
|
527
|
+
else if (this.check('IDENTIFIER')) {
|
|
528
|
+
color = this.advance().value;
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
else if (effectName !== 'stop') {
|
|
532
|
+
if (this.check('NUMBER')) {
|
|
533
|
+
intensity = Number(this.advance().value);
|
|
534
|
+
}
|
|
535
|
+
else if (this.check('IDENTIFIER')) {
|
|
536
|
+
intensity = this.advance().value;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
this.consumeOptionalNewline();
|
|
540
|
+
return {
|
|
541
|
+
type: 'VfxStmt',
|
|
542
|
+
effect: effectName,
|
|
543
|
+
intensity,
|
|
544
|
+
color,
|
|
545
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
parseCameraStmt() {
|
|
549
|
+
const startTok = this.consume('CAMERA', 'Expected "camera" keyword');
|
|
550
|
+
const actionTok = this.advance();
|
|
551
|
+
const actionName = actionTok.value.toLowerCase();
|
|
552
|
+
const validActions = ['shake', 'vpunch', 'hpunch', 'flash'];
|
|
553
|
+
if (!validActions.includes(actionName)) {
|
|
554
|
+
throw new KawaError({
|
|
555
|
+
code: 'E0108',
|
|
556
|
+
message: `Invalid camera action '${actionTok.value}'. Expected one of: ${validActions.join(', ')}`,
|
|
557
|
+
severity: 'error',
|
|
558
|
+
loc: actionTok.loc
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
let duration;
|
|
562
|
+
if (this.check('NUMBER')) {
|
|
563
|
+
duration = Number(this.advance().value);
|
|
564
|
+
}
|
|
565
|
+
this.consumeOptionalNewline();
|
|
566
|
+
return {
|
|
567
|
+
type: 'CameraStmt',
|
|
568
|
+
action: actionName,
|
|
569
|
+
duration,
|
|
570
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
parsePauseStmt() {
|
|
574
|
+
const startTok = this.consume('PAUSE', 'Expected "pause" keyword');
|
|
575
|
+
let duration;
|
|
576
|
+
if (this.check('NUMBER')) {
|
|
577
|
+
duration = Number(this.advance().value);
|
|
578
|
+
}
|
|
579
|
+
this.consumeOptionalNewline();
|
|
580
|
+
return {
|
|
581
|
+
type: 'PauseStmt',
|
|
582
|
+
duration,
|
|
583
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
parseCgStmt() {
|
|
587
|
+
const startTok = this.consume('CG', 'Expected "cg" keyword');
|
|
588
|
+
const imageTok = this.consume('STRING', 'Expected image filename or path in quotes after "cg"');
|
|
589
|
+
let unlockId;
|
|
590
|
+
if (this.match('AS')) {
|
|
591
|
+
if (this.check('STRING')) {
|
|
592
|
+
unlockId = this.advance().value;
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
unlockId = this.consume('IDENTIFIER', 'Expected unlock identifier after "as"').value;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
this.consumeOptionalNewline();
|
|
599
|
+
return {
|
|
600
|
+
type: 'CgStmt',
|
|
601
|
+
image: imageTok.value,
|
|
602
|
+
unlockId,
|
|
603
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
parseDefineDecl() {
|
|
607
|
+
const startTok = this.consume('DEFINE', 'Expected "define" keyword');
|
|
608
|
+
const nameParts = [];
|
|
609
|
+
while (this.checkSoftIdentifier()) {
|
|
610
|
+
nameParts.push(this.advance().value);
|
|
611
|
+
if (this.check('EQUALS') || this.check('STRING'))
|
|
612
|
+
break;
|
|
613
|
+
}
|
|
614
|
+
if (nameParts.length === 0) {
|
|
615
|
+
throw new KawaError({
|
|
616
|
+
code: 'E0110',
|
|
617
|
+
message: 'Expected alias name after "define"',
|
|
618
|
+
severity: 'error',
|
|
619
|
+
loc: this.currentLocation()
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
this.consume('EQUALS', 'Expected "=" after define name');
|
|
623
|
+
const valueTok = this.consume('STRING', 'Expected string value after "define ... ="');
|
|
624
|
+
this.consumeOptionalNewline();
|
|
625
|
+
return {
|
|
626
|
+
type: 'DefineDecl',
|
|
627
|
+
name: nameParts.join(' '),
|
|
628
|
+
value: valueTok.value,
|
|
629
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
parseInputStmt() {
|
|
633
|
+
const startTok = this.consume('INPUT', 'Expected "input" keyword');
|
|
634
|
+
const varTok = this.consume('IDENTIFIER', 'Expected variable name after "input"');
|
|
635
|
+
let prompt = 'Enter text';
|
|
636
|
+
if (this.check('STRING')) {
|
|
637
|
+
prompt = this.advance().value;
|
|
638
|
+
}
|
|
639
|
+
this.consumeOptionalNewline();
|
|
640
|
+
return {
|
|
641
|
+
type: 'InputStmt',
|
|
642
|
+
variable: varTok.value,
|
|
643
|
+
prompt,
|
|
644
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
parseWindowStmt() {
|
|
648
|
+
const startTok = this.consume('WINDOW', 'Expected "window" keyword');
|
|
649
|
+
let actionRaw;
|
|
650
|
+
if (this.check('SHOW') || this.check('HIDE') || this.check('IDENTIFIER')) {
|
|
651
|
+
actionRaw = this.advance().value.toLowerCase();
|
|
652
|
+
}
|
|
653
|
+
else {
|
|
654
|
+
throw new KawaError({
|
|
655
|
+
code: 'E0111',
|
|
656
|
+
message: 'Expected "show" or "hide" after "window"',
|
|
657
|
+
severity: 'error',
|
|
658
|
+
loc: this.currentLocation()
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
if (actionRaw !== 'show' && actionRaw !== 'hide') {
|
|
662
|
+
throw new KawaError({
|
|
663
|
+
code: 'E0111',
|
|
664
|
+
message: `Invalid window action '${actionRaw}'. Expected "show" or "hide".`,
|
|
665
|
+
severity: 'error',
|
|
666
|
+
loc: startTok.loc
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
this.consumeOptionalNewline();
|
|
670
|
+
return {
|
|
671
|
+
type: 'WindowStmt',
|
|
672
|
+
action: actionRaw,
|
|
673
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
parseThemeStmt() {
|
|
677
|
+
const startTok = this.consume('THEME', 'Expected "theme" keyword');
|
|
678
|
+
let name;
|
|
679
|
+
if (this.check('STRING') || this.checkSoftIdentifier()) {
|
|
680
|
+
name = this.advance().value;
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
throw new KawaError({
|
|
684
|
+
code: 'E0112',
|
|
685
|
+
message: 'Expected theme name after "theme"',
|
|
686
|
+
severity: 'error',
|
|
687
|
+
loc: this.currentLocation(),
|
|
688
|
+
hint: 'Example: theme "noir" or theme sakura'
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
this.consumeOptionalNewline();
|
|
692
|
+
return {
|
|
693
|
+
type: 'ThemeStmt',
|
|
694
|
+
name,
|
|
695
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
parseStyleStmt() {
|
|
699
|
+
const startTok = this.consume('STYLE', 'Expected "style" keyword');
|
|
700
|
+
if (!this.check('STRING') && !this.checkSoftIdentifier()) {
|
|
701
|
+
throw new KawaError({
|
|
702
|
+
code: 'E0113',
|
|
703
|
+
message: 'Expected style target after "style"',
|
|
704
|
+
severity: 'error',
|
|
705
|
+
loc: this.currentLocation(),
|
|
706
|
+
hint: 'Example: style dialogue glass\n Targets: dialogue, stage, root, choices'
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
const targetTok = this.advance();
|
|
710
|
+
if (!this.check('STRING') && !this.checkSoftIdentifier()) {
|
|
711
|
+
throw new KawaError({
|
|
712
|
+
code: 'E0113',
|
|
713
|
+
message: 'Expected style name after target',
|
|
714
|
+
severity: 'error',
|
|
715
|
+
loc: this.currentLocation(),
|
|
716
|
+
hint: 'Example: style dialogue glass'
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
const nameTok = this.advance();
|
|
720
|
+
this.consumeOptionalNewline();
|
|
721
|
+
return {
|
|
722
|
+
type: 'StyleStmt',
|
|
723
|
+
target: targetTok.value.toLowerCase(),
|
|
724
|
+
name: nameTok.value,
|
|
725
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
parseHotspotStmt() {
|
|
729
|
+
const startTok = this.consume('HOTSPOT', 'Expected "hotspot" keyword');
|
|
730
|
+
let id;
|
|
731
|
+
if (this.check('STRING') || this.checkSoftIdentifier()) {
|
|
732
|
+
id = this.advance().value;
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
throw new KawaError({
|
|
736
|
+
code: 'E0114',
|
|
737
|
+
message: 'Expected hotspot id after "hotspot"',
|
|
738
|
+
severity: 'error',
|
|
739
|
+
loc: this.currentLocation(),
|
|
740
|
+
hint: 'Example: hotspot door 40 50 18 12 jump courtyard\n Coordinates are percent of the stage (0–100) or fractions (0–1).'
|
|
741
|
+
});
|
|
742
|
+
}
|
|
743
|
+
const readCoord = (label) => {
|
|
744
|
+
if (!this.check('NUMBER')) {
|
|
745
|
+
throw new KawaError({
|
|
746
|
+
code: 'E0114',
|
|
747
|
+
message: `Expected ${label} coordinate after hotspot id`,
|
|
748
|
+
severity: 'error',
|
|
749
|
+
loc: this.currentLocation(),
|
|
750
|
+
hint: 'Example: hotspot door 40 50 18 12 jump courtyard'
|
|
751
|
+
});
|
|
752
|
+
}
|
|
753
|
+
return Number(this.advance().value);
|
|
754
|
+
};
|
|
755
|
+
const x = readCoord('x');
|
|
756
|
+
const y = readCoord('y');
|
|
757
|
+
const w = readCoord('w');
|
|
758
|
+
const h = readCoord('h');
|
|
759
|
+
if (!this.check('JUMP')) {
|
|
760
|
+
throw new KawaError({
|
|
761
|
+
code: 'E0114',
|
|
762
|
+
message: 'Expected "jump" after hotspot rectangle',
|
|
763
|
+
severity: 'error',
|
|
764
|
+
loc: this.currentLocation(),
|
|
765
|
+
hint: 'Example: hotspot door 40 50 18 12 jump courtyard'
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
this.advance();
|
|
769
|
+
const target = this.consume('IDENTIFIER', 'Expected target label after hotspot jump').value;
|
|
770
|
+
this.consumeOptionalNewline();
|
|
771
|
+
return {
|
|
772
|
+
type: 'HotspotStmt',
|
|
773
|
+
id,
|
|
774
|
+
x,
|
|
775
|
+
y,
|
|
776
|
+
w,
|
|
777
|
+
h,
|
|
778
|
+
targetLabel: target,
|
|
779
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
parseLayerStmt() {
|
|
783
|
+
const startTok = this.consume('LAYER', 'Expected "layer" keyword');
|
|
784
|
+
const name = this.check('STRING')
|
|
785
|
+
? this.advance().value
|
|
786
|
+
: this.consume('IDENTIFIER', 'Expected layer name (e.g. master, overlay)').value;
|
|
787
|
+
this.consumeOptionalNewline();
|
|
788
|
+
return {
|
|
789
|
+
type: 'LayerStmt',
|
|
790
|
+
name,
|
|
791
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
parseAnimateStmt() {
|
|
795
|
+
const startTok = this.consume('ANIMATE', 'Expected "animate" keyword');
|
|
796
|
+
const charTok = this.consume('IDENTIFIER', 'Expected character name after "animate"');
|
|
797
|
+
this.consume('WITH', 'Expected "with" after character in animate statement');
|
|
798
|
+
let animation;
|
|
799
|
+
let durationMs;
|
|
800
|
+
if (this.check('STRING')) {
|
|
801
|
+
const raw = this.advance().value.trim();
|
|
802
|
+
// "slide-in 400ms" | "slide-in 400" | "slide-in"
|
|
803
|
+
const match = raw.match(/^([a-zA-Z0-9_-]+)\s*(\d+)\s*(ms)?$/i) || raw.match(/^([a-zA-Z0-9_-]+)$/);
|
|
804
|
+
if (match) {
|
|
805
|
+
animation = match[1];
|
|
806
|
+
if (match[2])
|
|
807
|
+
durationMs = Number(match[2]);
|
|
808
|
+
}
|
|
809
|
+
else {
|
|
810
|
+
animation = raw.replace(/\s+/g, '-');
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
else {
|
|
814
|
+
animation = this.consume('IDENTIFIER', 'Expected animation name after "with"').value;
|
|
815
|
+
if (this.check('NUMBER')) {
|
|
816
|
+
durationMs = Number(this.advance().value);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
this.consumeOptionalNewline();
|
|
820
|
+
return {
|
|
821
|
+
type: 'AnimateStmt',
|
|
822
|
+
character: charTok.value,
|
|
823
|
+
animation: animation.toLowerCase(),
|
|
824
|
+
durationMs: Number.isFinite(durationMs) ? durationMs : undefined,
|
|
825
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
parseUnlockStmt() {
|
|
829
|
+
const startTok = this.consume('UNLOCK', 'Expected "unlock" keyword');
|
|
830
|
+
const id = this.check('STRING')
|
|
831
|
+
? this.advance().value
|
|
832
|
+
: this.consume('IDENTIFIER', 'Expected achievement id after "unlock"').value;
|
|
833
|
+
let title;
|
|
834
|
+
let description;
|
|
835
|
+
if (this.check('STRING')) {
|
|
836
|
+
title = this.advance().value;
|
|
837
|
+
if (this.check('STRING')) {
|
|
838
|
+
description = this.advance().value;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
this.consumeOptionalNewline();
|
|
842
|
+
return {
|
|
843
|
+
type: 'UnlockStmt',
|
|
844
|
+
id,
|
|
845
|
+
title,
|
|
846
|
+
description,
|
|
847
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
parseLangStmt() {
|
|
851
|
+
const startTok = this.consume('LANG', 'Expected "lang" keyword');
|
|
852
|
+
const code = this.check('STRING')
|
|
853
|
+
? this.advance().value
|
|
854
|
+
: this.consume('IDENTIFIER', 'Expected language code after "lang" (e.g. it, en)').value;
|
|
855
|
+
this.consumeOptionalNewline();
|
|
856
|
+
return {
|
|
857
|
+
type: 'LangStmt',
|
|
858
|
+
code: code.toLowerCase(),
|
|
859
|
+
loc: createLocation(this.file, startTok.loc.start, this.previousLocation().end)
|
|
860
|
+
};
|
|
861
|
+
}
|
|
415
862
|
parseBlock() {
|
|
416
863
|
this.consume('INDENT', 'Expected indented block');
|
|
417
864
|
const statements = [];
|
|
@@ -431,7 +878,13 @@ export class Parser {
|
|
|
431
878
|
readUntilColon() {
|
|
432
879
|
const parts = [];
|
|
433
880
|
while (!this.check('COLON') && !this.check('NEWLINE') && !this.isAtEnd()) {
|
|
434
|
-
|
|
881
|
+
const tok = this.advance();
|
|
882
|
+
if (tok.type === 'STRING') {
|
|
883
|
+
parts.push(JSON.stringify(tok.value));
|
|
884
|
+
}
|
|
885
|
+
else {
|
|
886
|
+
parts.push(tok.value);
|
|
887
|
+
}
|
|
435
888
|
}
|
|
436
889
|
return parts.join(' ');
|
|
437
890
|
}
|
|
@@ -445,6 +898,40 @@ export class Parser {
|
|
|
445
898
|
this.advance();
|
|
446
899
|
}
|
|
447
900
|
}
|
|
901
|
+
checkSoftIdentifier() {
|
|
902
|
+
if (this.isAtEnd())
|
|
903
|
+
return false;
|
|
904
|
+
const t = this.peek().type;
|
|
905
|
+
if (t === 'IDENTIFIER')
|
|
906
|
+
return true;
|
|
907
|
+
// Keywords may appear as names (e.g. `define music theme`, `hotspot window`, `play music theme`)
|
|
908
|
+
switch (t) {
|
|
909
|
+
case 'NEWLINE':
|
|
910
|
+
case 'INDENT':
|
|
911
|
+
case 'DEDENT':
|
|
912
|
+
case 'EOF':
|
|
913
|
+
case 'COLON':
|
|
914
|
+
case 'EQUALS':
|
|
915
|
+
case 'PLUS_EQUALS':
|
|
916
|
+
case 'MINUS_EQUALS':
|
|
917
|
+
case 'DOUBLE_EQUALS':
|
|
918
|
+
case 'NOT_EQUALS':
|
|
919
|
+
case 'GREATER_EQUALS':
|
|
920
|
+
case 'LESS_EQUALS':
|
|
921
|
+
case 'GREATER':
|
|
922
|
+
case 'LESS':
|
|
923
|
+
case 'PLUS':
|
|
924
|
+
case 'MINUS':
|
|
925
|
+
case 'COMMA':
|
|
926
|
+
case 'STRING':
|
|
927
|
+
case 'NUMBER':
|
|
928
|
+
case 'BOOLEAN':
|
|
929
|
+
case 'COLOR':
|
|
930
|
+
return false;
|
|
931
|
+
default:
|
|
932
|
+
return true;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
448
935
|
match(type) {
|
|
449
936
|
if (this.check(type)) {
|
|
450
937
|
this.advance();
|