com.robotsquid.squidhub 0.5.6 → 0.5.7
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/CHANGELOG.md +35 -0
- package/Editor/SquidHubBuildProcessor.cs +36 -0
- package/Editor/SquidHubBuildWindow.cs +291 -0
- package/Editor/SquidHubBuildWindow.cs.meta +11 -0
- package/Editor/SquidHubJson.cs +346 -0
- package/Editor/SquidHubJson.cs.meta +11 -0
- package/Editor/SquidHubManifest.cs +355 -0
- package/Editor/SquidHubManifest.cs.meta +11 -0
- package/Editor/SquidHubManifestWindow.cs +292 -0
- package/Editor/SquidHubManifestWindow.cs.meta +11 -0
- package/Editor/SquidHubUploadSettings.cs +40 -0
- package/Editor/SquidHubUploader.cs +136 -16
- package/README.md +45 -12
- package/package.json +1 -1
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
// The game's declaration: what it is, what it needs, and how it wants to be
|
|
2
|
+
// shown. `SquidHub → Game Manifest…` edits this; the post-build step writes it
|
|
3
|
+
// into the build folder; the server reads it back out of the uploaded bytes.
|
|
4
|
+
//
|
|
5
|
+
// ⚠️ THE SERVER READS `manifest.json` FROM THE UPLOADED FILES, never from
|
|
6
|
+
// anything the client posts alongside the "I'm finished" call — see
|
|
7
|
+
// `completeBuildUpload`, which is explicit that a posted manifest "is just a
|
|
8
|
+
// claim". So the ONLY way a declaration takes effect is for this file to be
|
|
9
|
+
// written into the build folder and uploaded with everything else.
|
|
10
|
+
//
|
|
11
|
+
// ⚠️ EDITING THE MANIFEST ALONE CHANGES NOTHING. `manifest.json` is excluded
|
|
12
|
+
// from the build hash (it contains that hash), so a build whose only change is
|
|
13
|
+
// its manifest is byte-identical to one already registered, reuses the existing
|
|
14
|
+
// immutable version row and is refused as a no-op with a `manifest-ignored`
|
|
15
|
+
// warning. A declaration change ships with a code change, or through the
|
|
16
|
+
// console override. `Warnings()` says so where a publisher will read it.
|
|
17
|
+
//
|
|
18
|
+
// ⚠️ Validation here MIRRORS `parseDisplay` in @robotsquid/contracts. The server
|
|
19
|
+
// refuses a malformed display block rather than dropping it, and finding that
|
|
20
|
+
// out after a twenty-minute WebGL build and a 30 MB upload is the expensive way
|
|
21
|
+
// to learn it. This is a courtesy copy — the server remains the authority.
|
|
22
|
+
//
|
|
23
|
+
// ⚠️ Pure System.* — no UnityEditor, no UnityEngine, so tools/test can run it.
|
|
24
|
+
using System;
|
|
25
|
+
using System.Collections.Generic;
|
|
26
|
+
using System.Globalization;
|
|
27
|
+
using System.IO;
|
|
28
|
+
using System.Linq;
|
|
29
|
+
using System.Text.RegularExpressions;
|
|
30
|
+
|
|
31
|
+
namespace SquidHub.Editor
|
|
32
|
+
{
|
|
33
|
+
internal sealed class SquidHubManifest
|
|
34
|
+
{
|
|
35
|
+
/// <summary>
|
|
36
|
+
/// ⚠️ GRANTED by the platform, never merely requested here. Ticking a box
|
|
37
|
+
/// declares that the game USES a capability; the broker still refuses any
|
|
38
|
+
/// verb the game has not been granted at review, and a new third-party
|
|
39
|
+
/// game has `saves` and `analytics` only. Declaring more does not grant
|
|
40
|
+
/// more — it is how you find out you need a grant.
|
|
41
|
+
/// </summary>
|
|
42
|
+
public static readonly string[] AllCapabilities =
|
|
43
|
+
{
|
|
44
|
+
"saves", "analytics", "achievements", "leaderboards",
|
|
45
|
+
"commerce.spend", "rewards", "multiplayer", "ads.rewarded",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/// <summary>What a new third-party game has until review grants more.</summary>
|
|
49
|
+
public static readonly string[] DefaultCapabilities = { "saves", "analytics" };
|
|
50
|
+
|
|
51
|
+
/// <summary>DEVICE orientation, not the game's own shape.</summary>
|
|
52
|
+
public static readonly string[] AllOrientations = { "landscape", "portrait" };
|
|
53
|
+
|
|
54
|
+
/// <summary>Sanity bounds on a declared ratio — 4:1 and 1:4. A typo catcher, not a taste judgement.</summary>
|
|
55
|
+
public const double AspectMinRatio = 0.25;
|
|
56
|
+
public const double AspectMaxRatio = 4.0;
|
|
57
|
+
|
|
58
|
+
public string GameId = "";
|
|
59
|
+
public string Title = "";
|
|
60
|
+
public string Version = "0.0.0";
|
|
61
|
+
public bool RequiresThreads;
|
|
62
|
+
public bool Sound = true;
|
|
63
|
+
public int SaveSchemaVersion = 1;
|
|
64
|
+
public bool SaveSchemaDowngrade;
|
|
65
|
+
public List<string> Capabilities = new List<string>(DefaultCapabilities);
|
|
66
|
+
public List<string> AnalyticsProps = new List<string>();
|
|
67
|
+
|
|
68
|
+
// display
|
|
69
|
+
public List<string> Orientations = new List<string>(AllOrientations);
|
|
70
|
+
public string AspectMin = "";
|
|
71
|
+
public string AspectMax = "";
|
|
72
|
+
public bool Fullscreen;
|
|
73
|
+
|
|
74
|
+
/// <summary>
|
|
75
|
+
/// The document this was read from, so keys we do not model survive.
|
|
76
|
+
///
|
|
77
|
+
/// ⚠️ `skus`, `achievements` and `rewardTable` live in the same file and
|
|
78
|
+
/// are not editable here. Rebuilding the manifest from the fields above
|
|
79
|
+
/// would delete a publisher's entire catalogue declaration, and they
|
|
80
|
+
/// would find out at the next release rather than at the edit.
|
|
81
|
+
/// </summary>
|
|
82
|
+
private JsonValue _document = JsonValue.NewObject();
|
|
83
|
+
|
|
84
|
+
public static SquidHubManifest Empty() => new SquidHubManifest();
|
|
85
|
+
|
|
86
|
+
/// <summary>Reads a manifest, or returns a default one when the file does not exist yet.</summary>
|
|
87
|
+
public static SquidHubManifest Load(string path)
|
|
88
|
+
{
|
|
89
|
+
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return new SquidHubManifest();
|
|
90
|
+
return FromJson(File.ReadAllText(path));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public static SquidHubManifest FromJson(string text)
|
|
94
|
+
{
|
|
95
|
+
var document = SquidHubJson.Parse(text);
|
|
96
|
+
if (document.Kind != JsonKind.Object)
|
|
97
|
+
throw new FormatException("manifest.json must be a JSON object");
|
|
98
|
+
|
|
99
|
+
var manifest = new SquidHubManifest { _document = document };
|
|
100
|
+
|
|
101
|
+
manifest.GameId = Field(document, "gameId").AsString();
|
|
102
|
+
manifest.Title = Field(document, "title").AsString();
|
|
103
|
+
manifest.Version = Field(document, "version").AsString("0.0.0");
|
|
104
|
+
manifest.RequiresThreads = Field(document, "requiresThreads").AsBool();
|
|
105
|
+
manifest.Sound = Field(document, "sound").AsBool(true);
|
|
106
|
+
manifest.SaveSchemaVersion = Field(document, "saveSchemaVersion").AsInt(1);
|
|
107
|
+
manifest.SaveSchemaDowngrade = Field(document, "saveSchemaDowngrade").AsBool();
|
|
108
|
+
|
|
109
|
+
var capabilities = document.Get("capabilities");
|
|
110
|
+
if (capabilities != null) manifest.Capabilities = capabilities.AsStringList();
|
|
111
|
+
|
|
112
|
+
var props = document.Get("analyticsProps");
|
|
113
|
+
if (props != null) manifest.AnalyticsProps = props.AsStringList();
|
|
114
|
+
|
|
115
|
+
var display = document.Get("display");
|
|
116
|
+
if (display != null && display.Kind == JsonKind.Object)
|
|
117
|
+
{
|
|
118
|
+
var orientations = display.Get("orientations");
|
|
119
|
+
if (orientations != null) manifest.Orientations = orientations.AsStringList();
|
|
120
|
+
|
|
121
|
+
var aspect = display.Get("aspect");
|
|
122
|
+
if (aspect != null && aspect.Kind == JsonKind.Object)
|
|
123
|
+
{
|
|
124
|
+
manifest.AspectMin = Field(aspect, "min").AsString();
|
|
125
|
+
manifest.AspectMax = Field(aspect, "max").AsString();
|
|
126
|
+
}
|
|
127
|
+
manifest.Fullscreen = Field(display, "fullscreen").AsBool();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return manifest;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private static JsonValue Field(JsonValue owner, string name) =>
|
|
134
|
+
owner.Get(name) ?? JsonValue.Null();
|
|
135
|
+
|
|
136
|
+
/// <summary>
|
|
137
|
+
/// The manifest as JSON, with every unmodelled key left exactly as it was.
|
|
138
|
+
///
|
|
139
|
+
/// ⚠️ `forBuild` strips `buildHash`, `files` and `sizeBytes`. Those
|
|
140
|
+
/// describe a specific set of bytes and are written by the release tool
|
|
141
|
+
/// at push time; carrying a copy from an earlier build into a new one
|
|
142
|
+
/// states a hash that is not this build's, and a manifest that lies about
|
|
143
|
+
/// its own identity is worse than one that stays quiet.
|
|
144
|
+
/// </summary>
|
|
145
|
+
public string ToJson(bool forBuild)
|
|
146
|
+
{
|
|
147
|
+
// ⚠️ A CLONE. Serialising must not edit the document the dialog is
|
|
148
|
+
// still showing — writing the build folder once stripped `buildHash`
|
|
149
|
+
// out of the publisher's own manifest as a side effect.
|
|
150
|
+
var document = _document.Clone();
|
|
151
|
+
|
|
152
|
+
document.Set("gameId", JsonValue.Str(GameId));
|
|
153
|
+
document.Set("title", JsonValue.Str(Title));
|
|
154
|
+
|
|
155
|
+
// ⚠️ Not editable. This package only ever produces Unity WebGL, and
|
|
156
|
+
// the portal frames /v/<hash>/index.html — a different entry is a
|
|
157
|
+
// build that registers perfectly and 404s for every player.
|
|
158
|
+
document.Set("engine", JsonValue.Str("unity"));
|
|
159
|
+
document.Set("entry", JsonValue.Str("index.html"));
|
|
160
|
+
|
|
161
|
+
document.Set("version", JsonValue.Str(string.IsNullOrWhiteSpace(Version) ? "0.0.0" : Version.Trim()));
|
|
162
|
+
document.Set("requiresThreads", JsonValue.Of(RequiresThreads));
|
|
163
|
+
document.Set("capabilities", JsonValue.ArrayOf(Capabilities));
|
|
164
|
+
document.Set("analyticsProps", JsonValue.ArrayOf(AnalyticsProps));
|
|
165
|
+
document.Set("saveSchemaVersion", JsonValue.Of(SaveSchemaVersion));
|
|
166
|
+
document.Set("sound", JsonValue.Of(Sound));
|
|
167
|
+
|
|
168
|
+
// ⚠️ Written only when true. It is an assertion that an older build
|
|
169
|
+
// can read a newer save, and an explicit `false` on every manifest
|
|
170
|
+
// invites someone to flip it without meeting that bar.
|
|
171
|
+
if (SaveSchemaDowngrade) document.Set("saveSchemaDowngrade", JsonValue.Of(true));
|
|
172
|
+
else document.Remove("saveSchemaDowngrade");
|
|
173
|
+
|
|
174
|
+
WriteDisplay(document);
|
|
175
|
+
|
|
176
|
+
if (forBuild)
|
|
177
|
+
{
|
|
178
|
+
document.Remove("buildHash");
|
|
179
|
+
document.Remove("files");
|
|
180
|
+
document.Remove("sizeBytes");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return SquidHubJson.Write(document);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/// <summary>
|
|
187
|
+
/// ⚠️ An entirely default display block is REMOVED, not written. `aspect:
|
|
188
|
+
/// null` means "fill the box", which is what the iframe did before the
|
|
189
|
+
/// feature existed — so a game that declares nothing must keep declaring
|
|
190
|
+
/// nothing rather than gaining a block that merely restates the default.
|
|
191
|
+
/// </summary>
|
|
192
|
+
private void WriteDisplay(JsonValue document)
|
|
193
|
+
{
|
|
194
|
+
var orientations = Orientations.Where(o => AllOrientations.Contains(o)).Distinct().ToList();
|
|
195
|
+
var hasOrientations = orientations.Count > 0 && orientations.Count < AllOrientations.Length;
|
|
196
|
+
var min = (AspectMin ?? "").Trim();
|
|
197
|
+
var max = (AspectMax ?? "").Trim();
|
|
198
|
+
var hasAspect = min.Length > 0 || max.Length > 0;
|
|
199
|
+
|
|
200
|
+
if (!hasOrientations && !hasAspect && !Fullscreen)
|
|
201
|
+
{
|
|
202
|
+
document.Remove("display");
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
var display = document.Get("display");
|
|
207
|
+
if (display == null || display.Kind != JsonKind.Object)
|
|
208
|
+
{
|
|
209
|
+
display = JsonValue.NewObject();
|
|
210
|
+
document.Set("display", display);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (hasOrientations) display.Set("orientations", JsonValue.ArrayOf(orientations));
|
|
214
|
+
else display.Remove("orientations");
|
|
215
|
+
|
|
216
|
+
if (hasAspect)
|
|
217
|
+
{
|
|
218
|
+
var aspect = display.Get("aspect");
|
|
219
|
+
if (aspect == null || aspect.Kind != JsonKind.Object)
|
|
220
|
+
{
|
|
221
|
+
aspect = JsonValue.NewObject();
|
|
222
|
+
display.Set("aspect", aspect);
|
|
223
|
+
}
|
|
224
|
+
if (min.Length > 0) aspect.Set("min", JsonValue.Str(min)); else aspect.Remove("min");
|
|
225
|
+
if (max.Length > 0) aspect.Set("max", JsonValue.Str(max)); else aspect.Remove("max");
|
|
226
|
+
}
|
|
227
|
+
else display.Remove("aspect");
|
|
228
|
+
|
|
229
|
+
if (Fullscreen) display.Set("fullscreen", JsonValue.Of(true));
|
|
230
|
+
else display.Remove("fullscreen");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
public void Save(string path, bool forBuild = false)
|
|
234
|
+
{
|
|
235
|
+
var directory = Path.GetDirectoryName(path);
|
|
236
|
+
if (!string.IsNullOrEmpty(directory)) Directory.CreateDirectory(directory);
|
|
237
|
+
File.WriteAllText(path, ToJson(forBuild));
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/// <summary>
|
|
241
|
+
/// Everything that would make the server refuse this build. Empty means
|
|
242
|
+
/// the declaration is well-formed — not that it will be granted.
|
|
243
|
+
/// </summary>
|
|
244
|
+
public List<string> Errors()
|
|
245
|
+
{
|
|
246
|
+
var errors = new List<string>();
|
|
247
|
+
|
|
248
|
+
if (string.IsNullOrWhiteSpace(GameId))
|
|
249
|
+
errors.Add("Game ID is required. It is the slug the game is published under.");
|
|
250
|
+
|
|
251
|
+
if (SaveSchemaVersion < 1)
|
|
252
|
+
{
|
|
253
|
+
errors.Add("Save schema version must be a positive integer. It is what the launch " +
|
|
254
|
+
"guard compares against a player's stored save, and the server ignores " +
|
|
255
|
+
"anything else and uses 1 — which would silently disable the guard.");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
foreach (var capability in Capabilities.Where(c => !AllCapabilities.Contains(c)))
|
|
259
|
+
errors.Add("'" + capability + "' is not a capability the platform knows.");
|
|
260
|
+
|
|
261
|
+
// ⚠️ Refused, never dropped. An empty orientation list reads as
|
|
262
|
+
// "playable in no orientation", and dropping it would leave the
|
|
263
|
+
// default — BOTH — silently widening a landscape-only game.
|
|
264
|
+
if (Orientations.Count == 0)
|
|
265
|
+
errors.Add("Tick at least one orientation. A game playable in neither cannot launch.");
|
|
266
|
+
|
|
267
|
+
foreach (var orientation in Orientations.Where(o => !AllOrientations.Contains(o)))
|
|
268
|
+
errors.Add("'" + orientation + "' is not an orientation. Use landscape or portrait.");
|
|
269
|
+
|
|
270
|
+
var min = RatioError("Minimum aspect", AspectMin);
|
|
271
|
+
if (min != null) errors.Add(min);
|
|
272
|
+
var max = RatioError("Maximum aspect", AspectMax);
|
|
273
|
+
if (max != null) errors.Add(max);
|
|
274
|
+
|
|
275
|
+
if (min == null && max == null)
|
|
276
|
+
{
|
|
277
|
+
var minRatio = ParseRatio(AspectMin);
|
|
278
|
+
var maxRatio = ParseRatio(AspectMax);
|
|
279
|
+
if (minRatio.HasValue && maxRatio.HasValue && minRatio.Value > maxRatio.Value)
|
|
280
|
+
{
|
|
281
|
+
errors.Add("The minimum aspect (" + AspectMin.Trim() + ") is wider than the maximum (" +
|
|
282
|
+
AspectMax.Trim() + ").");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return errors;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/// <summary>Things worth saying that do not stop a build.</summary>
|
|
290
|
+
public List<string> Warnings()
|
|
291
|
+
{
|
|
292
|
+
var warnings = new List<string>();
|
|
293
|
+
|
|
294
|
+
var ungranted = Capabilities
|
|
295
|
+
.Where(c => AllCapabilities.Contains(c) && !DefaultCapabilities.Contains(c))
|
|
296
|
+
.ToList();
|
|
297
|
+
if (ungranted.Count > 0)
|
|
298
|
+
{
|
|
299
|
+
warnings.Add("Declaring " + string.Join(", ", ungranted) + " does not grant it. " +
|
|
300
|
+
"Capabilities are granted at review; until then the broker answers " +
|
|
301
|
+
"DENIED to those verbs and the game must handle that.");
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (RequiresThreads)
|
|
305
|
+
{
|
|
306
|
+
warnings.Add("requiresThreads needs the isolated hosting tier. A build using " +
|
|
307
|
+
"SharedArrayBuffer cannot start on the default tier, and the pre-build " +
|
|
308
|
+
"check refuses it unless SQUIDHUB_ALLOW_THREADS is in the WebGL define symbols.");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (SaveSchemaDowngrade)
|
|
312
|
+
{
|
|
313
|
+
warnings.Add("saveSchemaDowngrade asserts that an older build can safely read a save " +
|
|
314
|
+
"written by this one. It disables the rollback guard, which is the only " +
|
|
315
|
+
"thing standing between a rollback and every player's progress.");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return warnings;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/// <summary>`"16:9"` → 1.777…. Null for anything that is not a positive ratio.</summary>
|
|
322
|
+
public static double? ParseRatio(string value)
|
|
323
|
+
{
|
|
324
|
+
if (string.IsNullOrWhiteSpace(value)) return null;
|
|
325
|
+
|
|
326
|
+
// ⚠️ COLON ONLY — never "16/9" and never a bare decimal. One spelling
|
|
327
|
+
// is one thing to document, parse and show back in an error, and
|
|
328
|
+
// "1.78" is both a plausible thing to type and a lossy thing to store.
|
|
329
|
+
var match = Regex.Match(value.Trim(), @"^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$");
|
|
330
|
+
if (!match.Success) return null;
|
|
331
|
+
|
|
332
|
+
var width = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
|
333
|
+
var height = double.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
|
334
|
+
|
|
335
|
+
// ⚠️ Zero on either side is a parse failure, not an infinity or a NaN.
|
|
336
|
+
// Both survive into the layout and land as a frame with no height.
|
|
337
|
+
if (width <= 0 || height <= 0) return null;
|
|
338
|
+
return width / height;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private static string RatioError(string label, string value)
|
|
342
|
+
{
|
|
343
|
+
if (string.IsNullOrWhiteSpace(value)) return null;
|
|
344
|
+
|
|
345
|
+
var ratio = ParseRatio(value);
|
|
346
|
+
if (!ratio.HasValue)
|
|
347
|
+
return label + ": '" + value.Trim() + "' is not a ratio like \"16:9\".";
|
|
348
|
+
|
|
349
|
+
if (ratio.Value < AspectMinRatio || ratio.Value > AspectMaxRatio)
|
|
350
|
+
return label + ": " + value.Trim() + " is outside 1:4–4:1.";
|
|
351
|
+
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
// SquidHub → Game Manifest… — the declaration, as a form.
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ Edits `manifest.json` beside `Assets/`, the same file `squidhub-release`
|
|
4
|
+
// reads. This window is a nicer way to write it, not a second place to keep it.
|
|
5
|
+
//
|
|
6
|
+
// ⚠️ It SAVES ON DEMAND, never on close or on play. The file is committed, and
|
|
7
|
+
// a window that rewrites a tracked file merely because it was opened produces
|
|
8
|
+
// diffs nobody asked for.
|
|
9
|
+
using System.Collections.Generic;
|
|
10
|
+
using System.IO;
|
|
11
|
+
using System.Linq;
|
|
12
|
+
using UnityEditor;
|
|
13
|
+
using UnityEngine;
|
|
14
|
+
|
|
15
|
+
namespace SquidHub.Editor
|
|
16
|
+
{
|
|
17
|
+
internal sealed class SquidHubManifestWindow : EditorWindow
|
|
18
|
+
{
|
|
19
|
+
private SquidHubManifest _manifest;
|
|
20
|
+
private string _loadError;
|
|
21
|
+
private string _propsText = "";
|
|
22
|
+
private Vector2 _scroll;
|
|
23
|
+
|
|
24
|
+
[MenuItem("SquidHub/Game Manifest…", priority = 1)]
|
|
25
|
+
internal static void Open()
|
|
26
|
+
{
|
|
27
|
+
var window = GetWindow<SquidHubManifestWindow>(true, "SquidHub Game Manifest", true);
|
|
28
|
+
window.minSize = new Vector2(520, 620);
|
|
29
|
+
window.Reload();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private void Reload()
|
|
33
|
+
{
|
|
34
|
+
_loadError = null;
|
|
35
|
+
try
|
|
36
|
+
{
|
|
37
|
+
_manifest = SquidHubManifest.Load(SquidHubUploadSettings.ManifestPath);
|
|
38
|
+
}
|
|
39
|
+
catch (System.Exception e)
|
|
40
|
+
{
|
|
41
|
+
// ⚠️ A manifest we cannot parse is NOT replaced with a default
|
|
42
|
+
// one. Offering an empty form over a file we failed to read is
|
|
43
|
+
// how a hand-authored catalogue gets overwritten by a Save.
|
|
44
|
+
_manifest = null;
|
|
45
|
+
_loadError = e.Message;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
_propsText = string.Join(", ", _manifest.AnalyticsProps);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private void OnGUI()
|
|
52
|
+
{
|
|
53
|
+
if (_loadError != null)
|
|
54
|
+
{
|
|
55
|
+
EditorGUILayout.HelpBox(
|
|
56
|
+
"manifest.json could not be read, so it is not being shown — saving over it " +
|
|
57
|
+
"would destroy whatever is in there.\n\n" + _loadError,
|
|
58
|
+
MessageType.Error);
|
|
59
|
+
if (GUILayout.Button("Reveal manifest.json"))
|
|
60
|
+
EditorUtility.RevealInFinder(SquidHubUploadSettings.ManifestPath);
|
|
61
|
+
if (GUILayout.Button("Try again")) Reload();
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (_manifest == null) Reload();
|
|
66
|
+
|
|
67
|
+
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
|
68
|
+
|
|
69
|
+
EditorGUILayout.LabelField("Identity", EditorStyles.boldLabel);
|
|
70
|
+
_manifest.GameId = EditorGUILayout.TextField(
|
|
71
|
+
new GUIContent("Game ID", "The slug the game is published under. It must match the game in the publisher console."),
|
|
72
|
+
_manifest.GameId);
|
|
73
|
+
_manifest.Title = EditorGUILayout.TextField("Title", _manifest.Title);
|
|
74
|
+
_manifest.Version = EditorGUILayout.TextField(
|
|
75
|
+
new GUIContent("Version", "semver. Defaults to 0.0.0 when absent."), _manifest.Version);
|
|
76
|
+
|
|
77
|
+
using (new EditorGUI.DisabledScope(true))
|
|
78
|
+
{
|
|
79
|
+
EditorGUILayout.TextField(new GUIContent("Engine",
|
|
80
|
+
"Fixed. This package only produces Unity WebGL builds."), "unity");
|
|
81
|
+
EditorGUILayout.TextField(new GUIContent("Entry",
|
|
82
|
+
"Fixed. The portal frames /v/<hash>/index.html."), "index.html");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
EditorGUILayout.Space();
|
|
86
|
+
DrawCapabilities();
|
|
87
|
+
|
|
88
|
+
EditorGUILayout.Space();
|
|
89
|
+
DrawSaves();
|
|
90
|
+
|
|
91
|
+
EditorGUILayout.Space();
|
|
92
|
+
DrawDisplay();
|
|
93
|
+
|
|
94
|
+
EditorGUILayout.Space();
|
|
95
|
+
EditorGUILayout.LabelField("Runtime", EditorStyles.boldLabel);
|
|
96
|
+
_manifest.Sound = EditorGUILayout.Toggle(
|
|
97
|
+
new GUIContent("Sound", "The game produces audio, so the portal draws its mute button."),
|
|
98
|
+
_manifest.Sound);
|
|
99
|
+
_manifest.RequiresThreads = EditorGUILayout.Toggle(
|
|
100
|
+
new GUIContent("Requires threads",
|
|
101
|
+
"SharedArrayBuffer. Needs the isolated hosting tier — a build using it cannot start on the default one."),
|
|
102
|
+
_manifest.RequiresThreads);
|
|
103
|
+
|
|
104
|
+
EditorGUILayout.Space();
|
|
105
|
+
DrawFindings();
|
|
106
|
+
|
|
107
|
+
EditorGUILayout.EndScrollView();
|
|
108
|
+
|
|
109
|
+
EditorGUILayout.Space();
|
|
110
|
+
DrawButtons();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private void DrawCapabilities()
|
|
114
|
+
{
|
|
115
|
+
EditorGUILayout.LabelField("Capabilities", EditorStyles.boldLabel);
|
|
116
|
+
EditorGUILayout.HelpBox(
|
|
117
|
+
"Tick what the game USES. Capabilities are granted by the platform at review — " +
|
|
118
|
+
"ticking one here does not grant it, and the broker answers DENIED to any verb " +
|
|
119
|
+
"this game has not been granted. New games have saves and analytics.",
|
|
120
|
+
MessageType.None);
|
|
121
|
+
|
|
122
|
+
foreach (var capability in SquidHubManifest.AllCapabilities)
|
|
123
|
+
{
|
|
124
|
+
var on = _manifest.Capabilities.Contains(capability);
|
|
125
|
+
var granted = SquidHubManifest.DefaultCapabilities.Contains(capability);
|
|
126
|
+
var label = new GUIContent(
|
|
127
|
+
capability + (granted ? "" : " (needs review)"),
|
|
128
|
+
granted
|
|
129
|
+
? "Granted to every game by default."
|
|
130
|
+
: "Declaring this does not grant it. Ask for it in the publisher console.");
|
|
131
|
+
|
|
132
|
+
var now = EditorGUILayout.ToggleLeft(label, on);
|
|
133
|
+
if (now == on) continue;
|
|
134
|
+
if (now) _manifest.Capabilities.Add(capability);
|
|
135
|
+
else _manifest.Capabilities.Remove(capability);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ⚠️ Shown rather than silently dropped. A capability we do not know
|
|
139
|
+
// is either a typo or a platform newer than this package, and both
|
|
140
|
+
// are things the publisher needs to see rather than lose on save.
|
|
141
|
+
var unknown = _manifest.Capabilities
|
|
142
|
+
.Where(c => !SquidHubManifest.AllCapabilities.Contains(c)).ToList();
|
|
143
|
+
if (unknown.Count > 0)
|
|
144
|
+
{
|
|
145
|
+
EditorGUILayout.HelpBox(
|
|
146
|
+
"manifest.json also declares " + string.Join(", ", unknown) +
|
|
147
|
+
", which this package does not recognise. It is kept as written.",
|
|
148
|
+
MessageType.Warning);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
EditorGUILayout.Space();
|
|
152
|
+
EditorGUILayout.LabelField(new GUIContent("Analytics properties",
|
|
153
|
+
"Comma separated. The property names analytics.track may carry."));
|
|
154
|
+
_propsText = EditorGUILayout.TextField(_propsText);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private void DrawSaves()
|
|
158
|
+
{
|
|
159
|
+
EditorGUILayout.LabelField("Saves", EditorStyles.boldLabel);
|
|
160
|
+
_manifest.SaveSchemaVersion = EditorGUILayout.IntField(
|
|
161
|
+
new GUIContent("Save schema version",
|
|
162
|
+
"Bump it when the save format changes. The launch guard compares this against a player's stored save."),
|
|
163
|
+
_manifest.SaveSchemaVersion);
|
|
164
|
+
|
|
165
|
+
_manifest.SaveSchemaDowngrade = EditorGUILayout.Toggle(
|
|
166
|
+
new GUIContent("Allow downgrade",
|
|
167
|
+
"Asserts an older build can safely read a save written by this one. It disables the rollback guard."),
|
|
168
|
+
_manifest.SaveSchemaDowngrade);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private void DrawDisplay()
|
|
172
|
+
{
|
|
173
|
+
EditorGUILayout.LabelField("Display", EditorStyles.boldLabel);
|
|
174
|
+
|
|
175
|
+
EditorGUILayout.LabelField(new GUIContent("Orientations",
|
|
176
|
+
"DEVICE orientation, not the game's shape. Unticking one raises the rotate prompt on a phone held that way. It does nothing on desktop."));
|
|
177
|
+
using (new EditorGUI.IndentLevelScope())
|
|
178
|
+
{
|
|
179
|
+
foreach (var orientation in SquidHubManifest.AllOrientations)
|
|
180
|
+
{
|
|
181
|
+
var on = _manifest.Orientations.Contains(orientation);
|
|
182
|
+
var now = EditorGUILayout.ToggleLeft(orientation, on);
|
|
183
|
+
if (now == on) continue;
|
|
184
|
+
if (now) _manifest.Orientations.Add(orientation);
|
|
185
|
+
else _manifest.Orientations.Remove(orientation);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
EditorGUILayout.LabelField(new GUIContent("Aspect window",
|
|
190
|
+
"A RANGE, as \"width:height\". A responsive game gives two different ratios; a fixed-canvas game gives the same one twice. Leave both blank to take whatever box you are given."));
|
|
191
|
+
using (new EditorGUI.IndentLevelScope())
|
|
192
|
+
{
|
|
193
|
+
_manifest.AspectMin = EditorGUILayout.TextField(
|
|
194
|
+
new GUIContent("Narrowest", "Pillarboxed below this. e.g. 4:3"), _manifest.AspectMin);
|
|
195
|
+
_manifest.AspectMax = EditorGUILayout.TextField(
|
|
196
|
+
new GUIContent("Widest", "Letterboxed above this. e.g. 21:9"), _manifest.AspectMax);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_manifest.Fullscreen = EditorGUILayout.Toggle(
|
|
200
|
+
new GUIContent("Fullscreen button", "The portal offers fullscreen for this game."),
|
|
201
|
+
_manifest.Fullscreen);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private void DrawFindings()
|
|
205
|
+
{
|
|
206
|
+
ApplyProps();
|
|
207
|
+
|
|
208
|
+
foreach (var error in _manifest.Errors())
|
|
209
|
+
EditorGUILayout.HelpBox(error, MessageType.Error);
|
|
210
|
+
|
|
211
|
+
foreach (var warning in _manifest.Warnings())
|
|
212
|
+
EditorGUILayout.HelpBox(warning, MessageType.Warning);
|
|
213
|
+
|
|
214
|
+
// ⚠️ The single most surprising rule on the platform, said where it
|
|
215
|
+
// is about to bite. manifest.json is excluded from the build hash, so
|
|
216
|
+
// a manifest-only change hashes to a build that already exists and is
|
|
217
|
+
// refused as a no-op.
|
|
218
|
+
EditorGUILayout.HelpBox(
|
|
219
|
+
"Changing only this file changes nothing. manifest.json is excluded from the build " +
|
|
220
|
+
"hash, so a build whose sole difference is its manifest is byte-identical to one " +
|
|
221
|
+
"already registered and the new declaration is ignored. Ship it with a code change, " +
|
|
222
|
+
"or set an override in the publisher console.",
|
|
223
|
+
MessageType.Info);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private void ApplyProps()
|
|
227
|
+
{
|
|
228
|
+
_manifest.AnalyticsProps = (_propsText ?? "")
|
|
229
|
+
.Split(',')
|
|
230
|
+
.Select(p => p.Trim())
|
|
231
|
+
.Where(p => p.Length > 0)
|
|
232
|
+
.Distinct()
|
|
233
|
+
.ToList();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
private void DrawButtons()
|
|
237
|
+
{
|
|
238
|
+
using (new EditorGUILayout.HorizontalScope())
|
|
239
|
+
{
|
|
240
|
+
var errors = _manifest.Errors();
|
|
241
|
+
using (new EditorGUI.DisabledScope(errors.Count > 0))
|
|
242
|
+
{
|
|
243
|
+
if (GUILayout.Button("Save"))
|
|
244
|
+
{
|
|
245
|
+
ApplyProps();
|
|
246
|
+
_manifest.Save(SquidHubUploadSettings.ManifestPath);
|
|
247
|
+
Debug.Log("[SquidHub] wrote " + SquidHubUploadSettings.ManifestPath);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (GUILayout.Button("Revert")) Reload();
|
|
252
|
+
if (GUILayout.Button("Reveal"))
|
|
253
|
+
EditorUtility.RevealInFinder(SquidHubUploadSettings.ManifestPath);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
EditorGUILayout.LabelField(SquidHubUploadSettings.ManifestPath, EditorStyles.miniLabel);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// <summary>
|
|
260
|
+
/// Writes the declaration into a finished build folder.
|
|
261
|
+
///
|
|
262
|
+
/// ⚠️ This is the ONLY thing that makes a declaration take effect. The
|
|
263
|
+
/// server reads `manifest.json` out of the uploaded bytes and explicitly
|
|
264
|
+
/// ignores any manifest posted alongside the upload, so a build folder
|
|
265
|
+
/// without this file declares nothing at all.
|
|
266
|
+
/// </summary>
|
|
267
|
+
internal static bool WriteInto(string buildFolder, out string problem)
|
|
268
|
+
{
|
|
269
|
+
problem = null;
|
|
270
|
+
SquidHubManifest manifest;
|
|
271
|
+
try
|
|
272
|
+
{
|
|
273
|
+
manifest = SquidHubManifest.Load(SquidHubUploadSettings.ManifestPath);
|
|
274
|
+
}
|
|
275
|
+
catch (System.Exception e)
|
|
276
|
+
{
|
|
277
|
+
problem = "manifest.json could not be read: " + e.Message;
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
var errors = manifest.Errors();
|
|
282
|
+
if (errors.Count > 0)
|
|
283
|
+
{
|
|
284
|
+
problem = string.Join("\n", errors);
|
|
285
|
+
return false;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
manifest.Save(Path.Combine(buildFolder, "manifest.json"), forBuild: true);
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|